From d7118741758326dfcf83ee8b60dbe07e1d11e4aa Mon Sep 17 00:00:00 2001 From: Jay Wang Date: Sun, 4 Feb 2024 05:18:05 -0500 Subject: [PATCH] Support indexed db for embeddings storage Signed-off-by: Jay Wang --- package.json | 7 +- src/mememo.ts | 76 ++++-- test/mememo.browser.test.ts | 447 ++++++++++++++++++++++++++++++++++++ vitest.config.browser.ts | 14 ++ 4 files changed, 520 insertions(+), 24 deletions(-) create mode 100644 test/mememo.browser.test.ts create mode 100644 vitest.config.browser.ts diff --git a/package.json b/package.json index a12df15..39483fa 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,10 @@ }, "homepage": "https://github.com/xiaohk/mememo#readme", "scripts": { - "test": "vitest", - "test:ui": "vitest --ui", - "test:run": "vitest run", + "test": "vitest test/mememo.test.ts", + "test:browser": "vitest -c vitest.config.browser.ts test/mememo.browser.test.ts", + "test:run": "vitest run test/mememo.test.ts && test:run:browser", + "test:run:browser": "vitest run -c vitest.config.browser.ts test/mememo.browser.test.ts", "coverage": "vitest run --coverage && c8 report && pnpm run coverage:badge", "coverage:badge": "pnpx make-coverage-badge --output-path ./imgs/coverage-badge.svg", "build": "pnpm run clean && vite build", diff --git a/src/mememo.ts b/src/mememo.ts index bdca943..c99f469 100644 --- a/src/mememo.ts +++ b/src/mememo.ts @@ -7,9 +7,10 @@ import { randomLcg, randomUniform } from 'd3-random'; import { MinHeap, MaxHeap, IGetCompareValue } from '@datastructures-js/heap'; import { openDB, IDBPDatabase } from 'idb'; +export type IDBValidKey = number | string | Date | BufferSource | IDBValidKey[]; type BuiltInDistanceFunction = 'cosine' | 'cosine-normalized'; -interface SearchNodeCandidate { +interface SearchNodeCandidate { key: T; distance: number; } @@ -67,12 +68,17 @@ interface HNSWConfig { /** Optional random seed. */ seed?: number; + + /** Whether to use indexedDB. If this is false, store all embeddings in + * the memory. Default to false so that MeMemo can be used in node.js. + */ + useIndexedDB?: boolean; } /** * A node in the HNSW graph. */ -class Node { +class Node { /** The unique key of an element. */ key: T; @@ -92,8 +98,9 @@ class Node { /** * An abstraction of a map storing nodes (in memory or in indexedDB) */ -class Nodes { +class Nodes { nodesMap: Map>; + indexedDBStoreName = ''; dbPromise: Promise> | null; constructor(indexedDBStoreName?: string) { @@ -101,6 +108,8 @@ class Nodes { this.dbPromise = null; if (indexedDBStoreName !== undefined) { + this.indexedDBStoreName = indexedDBStoreName; + // Create a new store this.dbPromise = openDB('mememo-index-store', 1, { upgrade(db) { @@ -121,7 +130,9 @@ class Nodes { if (this.dbPromise === null) { return this.nodesMap.size; } else { - return 1; + const db = await this.dbPromise; + const keys = await db.getAllKeys(this.indexedDBStoreName); + return keys.length; } } @@ -129,7 +140,9 @@ class Nodes { if (this.dbPromise === null) { return this.nodesMap.has(key); } else { - return false; + const db = await this.dbPromise; + const result = await db.getKey(this.indexedDBStoreName, key); + return result !== undefined; } } @@ -137,7 +150,11 @@ class Nodes { if (this.dbPromise === null) { return this.nodesMap.get(key); } else { - return undefined; + const db = await this.dbPromise; + const result = await (db.get(this.indexedDBStoreName, key) as Promise< + Node | undefined + >); + return result; } } @@ -145,7 +162,8 @@ class Nodes { if (this.dbPromise === null) { this.nodesMap.set(key, value); } else { - // pass + const db = await this.dbPromise; + await db.put(this.indexedDBStoreName, value, key); } } @@ -153,7 +171,8 @@ class Nodes { if (this.dbPromise === null) { this.nodesMap = new Map>(); } else { - // pass + const db = await this.dbPromise; + await db.clear(this.indexedDBStoreName); } } } @@ -161,7 +180,7 @@ class Nodes { /** * One graph layer in the HNSW index */ -class GraphLayer { +class GraphLayer { /** The graph maps a key to its neighbor and distances */ graph: Map>; @@ -178,7 +197,7 @@ class GraphLayer { /** * HNSW (Hierarchical Navigable Small World) class. */ -export class HNSW { +export class HNSW { distanceFunction: (a: number[], b: number[]) => number; /** The max number of neighbors for each node. */ @@ -219,6 +238,7 @@ export class HNSW { * have in the zero layer. Default 2 * m. * @param config.ml - Normalizer parameter. Default 1 / ln(m) * @param config.seed - Optional random seed. + * @param config.useIndexedDB - Whether to use indexedDB */ constructor({ distanceFunction, @@ -226,7 +246,8 @@ export class HNSW { efConstruction, mMax0, ml, - seed + seed, + useIndexedDB }: HNSWConfig) { // Initialize HNSW parameters this.m = m || 16; @@ -251,9 +272,14 @@ export class HNSW { } } + if (useIndexedDB === undefined || useIndexedDB === false) { + this.nodes = new Nodes(); + } else { + this.nodes = new Nodes('mememo-store'); + } + // Data structures this.graphLayers = []; - this.nodes = new Nodes(); } /** @@ -277,8 +303,8 @@ export class HNSW { } throw Error( - `There is already a node with key ${key} in the index. ` + - 'Use update() to update this node.' + `There is already a node with key ${JSON.stringify(key)} in the` + + 'index. Use update() to update this node.' ); } @@ -346,7 +372,9 @@ export class HNSW { for (const neighbor of selectedNeighbors) { const neighborNode = this.graphLayers[l].graph.get(neighbor.key); if (neighborNode === undefined) { - throw Error(`Can't find neighbor node ${neighbor.key}`); + throw Error( + `Can't find neighbor node ${JSON.stringify(neighbor.key)}` + ); } // Add the neighbor's existing neighbors as candidates @@ -396,7 +424,7 @@ export class HNSW { async update(key: T, value: number[]) { if (!(await this.nodes.has(key))) { throw Error( - `The node with key ${key} does not exist. ` + + `The node with key ${JSON.stringify(key)} does not exist. ` + 'Use insert() to add new node.' ); } @@ -431,7 +459,9 @@ export class HNSW { const firstDegreeNeighborNode = curGraphLayer.graph.get(firstDegreeNeighbor); if (firstDegreeNeighborNode === undefined) { - throw Error(`Can't find node with key ${firstDegreeNeighbor}`); + throw Error( + `Can't find node with key ${JSON.stringify(firstDegreeNeighbor)}` + ); } for (const secondDegreeNeighbor of firstDegreeNeighborNode.keys()) { @@ -511,7 +541,7 @@ export class HNSW { */ async markDeleted(key: T) { if (!(await this.nodes.has(key))) { - throw Error(`Node with key ${key} does not exist.`); + throw Error(`Node with key ${JSON.stringify(key)} does not exist.`); } // Special case: the user is trying to delete the entry point @@ -729,7 +759,9 @@ export class HNSW { const curNode = graphLayer.graph.get(curCandidate.key); if (curNode === undefined) { - throw Error(`Cannot find node with key ${curCandidate.key}`); + throw Error( + `Cannot find node with key ${JSON.stringify(curCandidate.key)}` + ); } for (const key of curNode.keys()) { @@ -802,7 +834,9 @@ export class HNSW { // Update candidates and found nodes using the current node's neighbors const curNode = graphLayer.graph.get(nearestCandidate.key); if (curNode === undefined) { - throw Error(`Cannot find node with key ${nearestCandidate.key}`); + throw Error( + `Cannot find node with key ${JSON.stringify(nearestCandidate.key)}` + ); } for (const neighborKey of curNode.keys()) { @@ -927,7 +961,7 @@ export class HNSW { async _getNodeInfo(key: T) { const node = await this.nodes.get(key); if (node === undefined) { - throw Error(`Can't find node with key ${key}`); + throw Error(`Can't find node with key ${JSON.stringify(key)}`); } return node; } diff --git a/test/mememo.browser.test.ts b/test/mememo.browser.test.ts new file mode 100644 index 0000000..ca3b78a --- /dev/null +++ b/test/mememo.browser.test.ts @@ -0,0 +1,447 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { HNSW } from '../src/mememo'; +import { randomLcg, randomUniform } from 'd3-random'; +import embeddingDataJSON from './data/accident-report-embeddings-100.json'; +import graph10Layer1JSON from './data/insert-10-1-layer.json'; +import graph10Layer2JSON from './data/insert-10-2-layer.json'; +import graph30Layer3JSON from './data/insert-30-3-layer.json'; +import graph100Layer6JSON from './data/insert-100-6-layer.json'; +import graph100Layer3M3JSON from './data/insert-100-3-layer-m=3.json'; +import graph50Update10JSON from './data/update-50-3-layer-10.json'; +import graph50Delete1JSON from './data/delete-50-insert-30-delete-20-insert-20.json'; +import graph50Delete2JSON from './data/delete-50-insert-30-delete-20-undelete-10-insert-20.json'; +import query1JSON from './data/query-50.json'; + +interface EmbeddingData { + embeddings: number[][]; + reportNumbers: number[]; +} + +type GraphLayer = Record>; + +type QueryResult = [string, number]; + +interface QueryData { + i: number; + k: number; + result: QueryResult[]; +} + +const embeddingData = embeddingDataJSON as EmbeddingData; + +const graph10Layer1 = graph10Layer1JSON as GraphLayer[]; +const graph10Layer2 = graph10Layer2JSON as GraphLayer[]; +const graph30Layer3 = graph30Layer3JSON as GraphLayer[]; +const graph100Layer6 = graph100Layer6JSON as GraphLayer[]; +const graph100Layer3M3 = graph100Layer3M3JSON as GraphLayer[]; +const graph50Update10 = graph50Update10JSON as GraphLayer[]; +const graph50Delete1 = graph50Delete1JSON as GraphLayer[]; +const graph50Delete2 = graph50Delete2JSON as GraphLayer[]; + +const query1 = query1JSON as QueryData[]; + +const useIndexedDB = true; + +/** + * Check if the graphs in HNSW match the expected graph layers from json + * @param reportIDs Report IDs in the hnsw + * @param hnsw HNSW index + * @param expectedGraphs Expected graph layers loaded from json + */ +const _checkGraphLayers = ( + reportIDs: string[], + hnsw: HNSW, + expectedGraphs: GraphLayer[] +) => { + for (const reportID of reportIDs) { + for (const [l, graphLayer] of hnsw.graphLayers.entries()) { + const curNode = graphLayer.graph.get(reportID); + + if (curNode === undefined) { + expect(expectedGraphs[l][reportID]).toBeUndefined(); + } else { + expect(expectedGraphs[l][reportID]).not.to.toBeUndefined(); + // Check the distances + const expectedNeighbors = expectedGraphs[l][reportID]; + for (const [neighborKey, neighborDistance] of curNode.entries()) { + expect(expectedNeighbors[neighborKey]).to.not.toBeUndefined(); + expect(neighborDistance).toBeCloseTo( + expectedNeighbors[neighborKey]!, + 1e-6 + ); + } + } + } + } +}; + +//==========================================================================|| +// Insert || +//==========================================================================|| + +describe('insert()', () => { + it('insert() 10 items, 1 layer', async () => { + const hnsw = new HNSW({ + distanceFunction: 'cosine', + seed: 20240101, + useIndexedDB + }); + + // Insert 10 embeddings + const size = 10; + + // The random levels with this seed is 0,0,0,0,0,0,0,0,0,0 + const reportIDs: string[] = []; + for (let i = 0; i < size; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + // There should be only one layer, and all nodes are fully connected + expect(hnsw.graphLayers.length).toBe(1); + _checkGraphLayers(reportIDs, hnsw, graph10Layer1); + }); + + it('insert() 10 items, 2 layer', async () => { + const hnsw = new HNSW({ + distanceFunction: 'cosine', + seed: 10, + useIndexedDB + }); + + // Insert 10 embeddings + const size = 10; + + // The random levels with this seed is 0,0,0,1,1,0,0,0,0,0 + const reportIDs: string[] = []; + for (let i = 0; i < size; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + expect(hnsw.graphLayers.length).toBe(2); + _checkGraphLayers(reportIDs, hnsw, graph10Layer2); + }); + + it('insert() 30 items, 3 layer', async () => { + const hnsw = new HNSW({ + distanceFunction: 'cosine', + seed: 262, + useIndexedDB + }); + + // Insert 30 embeddings + const size = 30; + + // The random levels with seed 262 is: [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1, 0, 1, 1, 0, 0, 0] + const reportIDs: string[] = []; + for (let i = 0; i < size; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + expect(hnsw.graphLayers.length).toBe(3); + _checkGraphLayers(reportIDs, hnsw, graph30Layer3); + }); + + it('insert() 100 items, 6 layer', async () => { + const hnsw = new HNSW({ + distanceFunction: 'cosine', + seed: 11906, + useIndexedDB + }); + + // Insert 100 embeddings + const size = 100; + + // The random levels with seed 11906 is: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0, 0, 0, 0, 3, 0, 5, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, + // 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0] + const reportIDs: string[] = []; + for (let i = 0; i < size; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + expect(hnsw.graphLayers.length).toBe(6); + _checkGraphLayers(reportIDs, hnsw, graph100Layer6); + }); + + it('insert() 100 items, 3 layer, m=3', async () => { + const hnsw = new HNSW({ + distanceFunction: 'cosine', + seed: 21574, + m: 3, + useIndexedDB + }); + + // Insert 100 embeddings + const size = 100; + + // The random levels with seed 21574 (need to manually set it, because it + // would change since m and ml are different from default) + const levels = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 2, 0, 0, + 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, 1, 0, 0, 0, 0, + 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0 + ]; + + const reportIDs: string[] = []; + for (let i = 0; i < size; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i], levels[i]); + } + + expect(hnsw.graphLayers.length).toBe(3); + _checkGraphLayers(reportIDs, hnsw, graph100Layer3M3); + }); + + it.skip('Find random seeds', () => { + // Find random seed that give a nice level sequence to test + const size = 50; + const start = 100000; + for (let i = start; i < start + 100000; i++) { + const rng = randomLcg(i); + const curLevels: number[] = []; + const ml = 1 / Math.log(16); + + for (let j = 0; j < size; j++) { + const level = Math.floor(-Math.log(rng()) * ml); + curLevels.push(level); + } + + if (Math.max(...curLevels) < 4) { + const levelSum = curLevels.reduce((sum, value) => sum + value, 0); + if (levelSum > 12) { + console.log('Good seed: ', i); + console.log(curLevels); + break; + } + } + } + }); +}); + +//==========================================================================|| +// Update || +//==========================================================================|| + +describe('update()', () => { + it('update() 10 / 50 items', async () => { + const hnsw = new HNSW({ + distanceFunction: 'cosine', + seed: 65975, + useIndexedDB + }); + + // Insert 50 embeddings + const size = 50; + + // The random levels with this seed is [ 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, + // 1, 1, 0, 0, 1, 0, 0, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0, 0, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0 ] + const reportIDs: string[] = []; + for (let i = 0; i < size; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + // Update 10 nodes + const updateIndexes = [ + [3, 71], + [6, 63], + [36, 82], + [9, 67], + [31, 91], + [1, 55], + [43, 65], + [4, 85], + [37, 61], + [45, 86] + ]; + + for (const pair of updateIndexes) { + const oldKey = String(embeddingData.reportNumbers[pair[0]]); + const newValue = embeddingData.embeddings[pair[1]]; + await hnsw.update(oldKey, newValue); + } + + expect(hnsw.graphLayers.length).toBe(3); + _checkGraphLayers(reportIDs, hnsw, graph50Update10); + }); +}); + +//==========================================================================|| +// Delete || +//==========================================================================|| + +describe('markDelete()', () => { + it('markDelete(): insert 30 => delete 20 => insert 20', async () => { + const hnsw = new HNSW({ + distanceFunction: 'cosine', + seed: 113082, + useIndexedDB + }); + + // Insert 50 embeddings + const size = 50; + + // The random levels with this seed is [1, 0, 2, 0, 0, 0, 0, 1, 1, 0, 2, 0, + // 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, + // 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0] + const reportIDs: string[] = []; + + // Insert 30 nodes + for (let i = 0; i < 30; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + // Delete 30 random nodes + const deleteIndexes = [ + 7, 12, 4, 14, 20, 27, 5, 21, 2, 19, 10, 15, 24, 6, 3, 0, 22, 8, 11, 1 + ]; + + for (const i of deleteIndexes) { + const key = String(embeddingData.reportNumbers[i]); + await hnsw.markDeleted(key); + } + + // Insert the rest 20 nodes + for (let i = 30; i < size; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + expect(hnsw.graphLayers.length).toBe(2); + _checkGraphLayers(reportIDs, hnsw, graph50Delete1); + }); + + it('markDelete(): insert 30 => delete 20 => un-delete 10 => insert 20', async () => { + const hnsw = new HNSW({ + distanceFunction: 'cosine', + seed: 113082, + useIndexedDB + }); + + // Insert 50 embeddings + const size = 50; + + // The random levels with this seed is [1, 0, 2, 0, 0, 0, 0, 1, 1, 0, 2, 0, + // 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, + // 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0] + const reportIDs: string[] = []; + + // Insert 30 nodes + for (let i = 0; i < 30; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + // Delete 20 random nodes + const deleteIndexes = [ + 7, 12, 4, 14, 20, 27, 5, 21, 2, 19, 10, 15, 24, 6, 3, 0, 22, 8, 11, 1 + ]; + + for (const i of deleteIndexes) { + const key = String(embeddingData.reportNumbers[i]); + await hnsw.markDeleted(key); + } + + // Un-delete 10 random nodes + const unDeleteIndexes = [12, 22, 4, 14, 19, 5, 2, 15, 21, 0]; + + for (const i of unDeleteIndexes) { + const key = String(embeddingData.reportNumbers[i]); + await hnsw.unMarkDeleted(key); + } + + // Insert the rest 20 nodes + for (let i = 30; i < size; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + expect(hnsw.graphLayers.length).toBe(2); + // graph50Delete2 is actually the same as graph50Delete1 + _checkGraphLayers(reportIDs, hnsw, graph50Delete2); + }); +}); + +//==========================================================================|| +// query || +//==========================================================================|| + +describe('query()', () => { + it('query(): 90/50 items, insert 30 => delete 20 => un-delete 10 => insert 20', async () => { + const hnsw = new HNSW({ + distanceFunction: 'cosine', + seed: 113082, + useIndexedDB + }); + + // Insert 50 embeddings + const size = 50; + + // The random levels with this seed is [1, 0, 2, 0, 0, 0, 0, 1, 1, 0, 2, 0, + // 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, + // 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0] + const reportIDs: string[] = []; + + // Insert 30 nodes + for (let i = 0; i < 30; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + // Delete 20 random nodes + const deleteIndexes = [ + 7, 12, 4, 14, 20, 27, 5, 21, 2, 19, 10, 15, 24, 6, 3, 0, 22, 8, 11, 1 + ]; + + for (const i of deleteIndexes) { + const key = String(embeddingData.reportNumbers[i]); + await hnsw.markDeleted(key); + } + + // Un-delete 10 random nodes + const unDeleteIndexes = [12, 22, 4, 14, 19, 5, 2, 15, 21, 0]; + + for (const i of unDeleteIndexes) { + const key = String(embeddingData.reportNumbers[i]); + await hnsw.unMarkDeleted(key); + } + + // Insert the rest 20 nodes + for (let i = 30; i < size; i++) { + const curReportID = String(embeddingData.reportNumbers[i]); + reportIDs.push(curReportID); + await hnsw.insert(curReportID, embeddingData.embeddings[i]); + } + + // Check query results + for (const q of query1) { + const myResults = await hnsw.query(embeddingData.embeddings[q.i], q.k); + expect(myResults.length).toBe(q.result.length); + for (const [i, myResult] of myResults.entries()) { + expect(myResult.key).toBe(q.result[i][0]); + expect(myResult.distance).toBeCloseTo(q.result[i][1], 4); + } + } + }); +}); diff --git a/vitest.config.browser.ts b/vitest.config.browser.ts new file mode 100644 index 0000000..2e7be79 --- /dev/null +++ b/vitest.config.browser.ts @@ -0,0 +1,14 @@ +/// + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + // Configuration specific to the second group of tests + test: { + // Test-specific configurations + browser: { + enabled: true, + name: 'chrome' + } + } +});