UNPKG

surch

Version:

Create and query searchable document indices.

696 lines (562 loc) 20.7 kB
/* eslint-disable no-shadow, max-nested-callbacks */ /* eslint-env mocha */ 'use strict' const { assert } = require('chai') const surch = require('.') test('create does not throw with valid targetKey', () => assert.doesNotThrow(() => surch.create('foo')) ) test('create throws with missing targetKey', () => assert.throws(() => surch.create()) ) test('create throws with empty targetKey', () => assert.throws(() => surch.create('')) ) test('create throws with invalid targetKey', () => assert.throws(() => surch.create({})) ) test('create throws with empty idKey', () => assert.throws(() => surch.create('foo', { idKey: '' })) ) test('create throws with invalid idKey', () => assert.throws(() => surch.create('foo', { idKey: {} })) ) test('create throws with invalid minLength', () => assert.throws(() => surch.create('foo', { minLength: 3.5 })) ) test('create throws with negative minLength', () => assert.throws(() => surch.create('foo', { minLength: -3 })) ) test('create throws with invalid caseSensitive', () => assert.throws(() => surch.create('foo', { caseSensitive: 1 })) ) test('create throws with invalid strict', () => assert.throws(() => surch.create('foo', { strict: 'true' })) ) test('create throws with non-function coerceId', () => assert.throws(() => surch.create('foo', { coerceId: { length: 1 } })) ) test('create throws with invalid coerceId', () => assert.throws(() => surch.create('foo', { coerceId () {} })) ) suite('create with default arguments:', () => { let index setup(() => index = surch.create('foo') ) test('index has 5 properties', () => assert.lengthOf(Object.keys(index), 5) ) test('index has add method', () => assert.isFunction(index.add) ) test('add expects 1 argument', () => assert.lengthOf(index.add, 1) ) test('index has delete method', () => assert.isFunction(index.delete) ) test('delete expects 1 argument', () => assert.lengthOf(index.delete, 1) ) test('index has update method', () => assert.isFunction(index.update) ) test('update expects 1 argument', () => assert.lengthOf(index.update, 1) ) test('index has clear method', () => assert.isFunction(index.clear) ) test('clear expects no arguments', () => assert.lengthOf(index.clear, 0) ) test('index has search method', () => assert.isFunction(index.search) ) test('search expects 1 argument', () => assert.lengthOf(index.search, 1) ) test('add throws with invalid property', () => assert.throws(() => index.add({ _id: 0, foo: {} })) ) test('add throws with missing id', () => assert.throws(() => index.add({ _id: null, foo: 'bar' })) ) test('add does not throw with missing value', () => assert.doesNotThrow(() => index.add({ _id: 0 })) ) test('add does not throw with short value', () => assert.doesNotThrow(() => index.add({ _id: 0, foo: 'ba' })) ) test('add does not throw with extra value', () => assert.doesNotThrow(() => index.add({ _id: 0, foo: 'bar', baz: 'qux' })) ) test('delete throws with invalid documentId', () => assert.throws(() => index.delete(42)) ) test('update throws with invalid documentId', () => assert.throws(() => { index.update({ _id: 42, foo: 'bar' }) }) ) test('update throws with invalid property', () => assert.throws(() => { index.add({ _id: 42, foo: 'bar' }) index.update({ _id: 42, foo: {} }) }) ) test('update does not throw with missing value', () => assert.doesNotThrow(() => { index.add({ _id: 42, foo: 'bar' }) index.update({ _id: 42 }) }) ) test('update does not throw with short value', () => assert.doesNotThrow(() => { index.add({ _id: 42, foo: 'bar' }) index.update({ _id: 42, foo: 'ba' }) }) ) test('update does not throw with extra value', () => assert.doesNotThrow(() => { index.add({ _id: 42, foo: 'bar' }) index.update({ _id: 42, foo: 'bar', baz: 'qux' }) }) ) test('clear does not throw', () => assert.doesNotThrow(() => index.clear()) ) test('search throws with invalid query', () => assert.throws(() => index.search({})) ) test('search throws with short query', () => assert.throws(() => index.search('ba')) ) test('search returns empty array with no matches', () => assert.deepEqual(index.search('bar'), []) ) suite('add with minimum length property:', () => { setup(() => index.add({ _id: 0, foo: 'bar', baz: 'qux' }) ) test('add throws with duplicate id', () => assert.throws(() => index.add({ _id: 0, foo: 'bar' })) ) test('search with 1 match returns correct result', () => assert.deepEqual(index.search('bar'), [ { id: 0, match: 'bar', indices: [ 0 ], score: 100 } ]) ) test('search for wrong property returns empty result', () => assert.deepEqual(index.search('qux'), []) ) suite('add with similar property:', () => { setup(() => index.add({ _id: 1, foo: 'barb' }) ) test('search with 2 matches returns correct result', () => assert.deepEqual(index.search('bar'), [ { id: 0, match: 'bar', indices: [ 0 ], score: 100 }, { id: 1, match: 'barb', indices: [ 0 ], score: 75 } ]) ) test('search with 1 match returns correct result', () => assert.deepEqual(index.search('barb'), [ { id: 1, match: 'barb', indices: [ 0 ], score: 100 } ]) ) test('search with 1 match not at start of string returns correct result', () => assert.deepEqual(index.search('arb'), [ { id: 1, match: 'barb', indices: [ 1 ], score: 75 } ]) ) suite('delete:', () => { setup(() => index.delete(1) ) test('search with former match returns empty result', () => assert.deepEqual(index.search('barb'), []) ) test('search with remaining match returns correct result', () => assert.deepEqual(index.search('bar'), [ { id: 0, match: 'bar', indices: [ 0 ], score: 100 } ]) ) }) suite('update:', () => { setup(() => index.update({ _id: 1, foo: 'wibble' }) ) test('search with former match returns empty result', () => assert.deepEqual(index.search('barb'), []) ) test('search with remaining match returns correct result', () => assert.deepEqual(index.search('bar'), [ { id: 0, match: 'bar', indices: [ 0 ], score: 100 } ]) ) test('search with new match returns correct result', () => assert.deepEqual(index.search('ibb'), [ { id: 1, match: 'wibble', indices: [ 1 ], score: 50 } ]) ) }) suite('clear:', () => { setup(() => index.clear() ) test('search with former match returns empty result', () => assert.deepEqual(index.search('bar'), []) ) test('search with other former match returns empty result', () => assert.deepEqual(index.search('barb'), []) ) }) }) }) suite('add with repetitive text:', () => { setup(() => index.add({ _id: 0, foo: 'xfooxfooxfoo' }) ) test('search returns result array with 1 match', () => assert.deepEqual(index.search('foo'), [ { id: 0, match: 'xfooxfooxfoo', indices: [ 1, 5, 9 ], score: 25 } ]) ) test('search returns empty result without match', () => assert.deepEqual(index.search('xfooo'), []) ) suite('add with similar repetitive text:', () => { setup(() => index.add({ _id: 1, foo: 'foodfoodfood' }) ) test('search returns results in index order if scores are equal', () => assert.deepEqual(index.search('foo'), [ { id: 1, match: 'foodfoodfood', indices: [ 0, 4, 8 ], score: 25 }, { id: 0, match: 'xfooxfooxfoo', indices: [ 1, 5, 9 ], score: 25 } ]) ) }) }) suite('add with whitespace and punctuation:', () => { setup(() => { index.add({ _id: 0, foo: 'The King & Queen' }) index.add({ _id: 1, foo: 'The Queen\'s Head' }) index.add({ _id: 2, foo: 'The King\'s Arms' }) }) test('search with punctuation match returns correct result', () => assert.deepEqual(index.search('Queen\'s Head'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 4, 12 ], score: 75 } ]) ) test('search with punctuation difference returns correct result', () => assert.deepEqual(index.search('Queens Head'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 4, 12 ], score: 69 } ]) ) test('search with wrong order returns empty result', () => assert.deepEqual(index.search('en\'sQue Head'), []) ) test('search with wrong case returns correct result', () => assert.deepEqual(index.search('Queen\'s head'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 4, 12 ], score: 75 } ]) ) test('search with missing whitespace returns empty result', () => assert.deepEqual(index.search('QueensHead'), []) ) test('search with partial matches returns correct result', () => assert.deepEqual(index.search('en\'s ead'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 7, 13 ], score: 50 } ]) ) }) suite('add with same word in different cases:', () => { setup(() => index.add({ _id: 0, foo: 'The quick brown fox jumps over the lazy dog.' }) ) test('search with one case returns indices for both cases', () => assert.deepEqual(index.search('the'), [ { id: 0, match: 'The quick brown fox jumps over the lazy dog.', indices: [ 0, 31 ], score: 7 } ]) ) suite('add with subset of the same string:', () => { setup(() => index.add({ _id: 1, foo: 'The quick brown fox jumps over the dog.' }) ) test('search with common substring returns results in score order', () => assert.deepEqual(index.search('the'), [ { id: 1, match: 'The quick brown fox jumps over the dog.', indices: [ 0, 31 ], score: 8 }, { id: 0, match: 'The quick brown fox jumps over the lazy dog.', indices: [ 0, 31 ], score: 7 } ]) ) }) }) }) suite('create with different idKey:', () => { let index setup(() => { index = surch.create('foo', { idKey: 'bar' }) index.add({ bar: 'baz', foo: 'The quick brown fox jumps over the lazy dog.' }) }) test('search with 1 match returns correct result', () => assert.deepEqual(index.search('the'), [ { id: 'baz', match: 'The quick brown fox jumps over the lazy dog.', indices: [ 0, 31 ], score: 7 } ]) ) }) suite('create with minLength=4:', () => { let index setup(() => { index = surch.create('foo', { minLength: 4 }) index.add({ _id: 0, foo: 'The quick brown fox jumps over the lazy dog.' }) }) test('search throws with short query', () => assert.throws(() => index.search('the')) ) test('search with 1 match returns correct result', () => assert.deepEqual(index.search('lazy'), [ { id: 0, match: 'The quick brown fox jumps over the lazy dog.', indices: [ 35 ], score: 9 } ]) ) }) suite('create with caseSensitive=true:', () => { let index setup(() => { index = surch.create('wibble', { caseSensitive: true }) index.add({ _id: 0, wibble: 'The quick brown fox jumps over the lazy dog.' }) }) test('search with wrong case returns empty result', () => assert.deepEqual(index.search('thE'), []) ) test('search with one case returns indices and score for the correct case', () => assert.deepEqual(index.search('the'), [ { id: 0, match: 'The quick brown fox jumps over the lazy dog.', indices: [ 31 ], score: 7 } ]) ) }) suite('create with strict=true:', () => { let index setup(() => { index = surch.create('wibble', { strict: true }) index.add({ _id: 0, wibble: 'The King & Queen' }) index.add({ _id: 1, wibble: 'The Queen\'s Head' }) index.add({ _id: 2, wibble: 'The King\'s Arms' }) }) test('search with punctuation match returns correct result', () => assert.deepEqual(index.search('g\'s A'), [ { id: 2, match: 'The King\'s Arms', indices: [ 7 ], score: 33 } ]) ) test('search with punctuation difference returns correct result', () => assert.deepEqual(index.search('gsA'), [ { id: 2, match: 'The King\'s Arms', indices: [ 7 ], score: 20 } ]) ) test('search with wrong case returns correct result', () => assert.deepEqual(index.search('nsh'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 8 ], score: 19 } ]) ) test('search with wrong order returns empty result', () => assert.deepEqual(index.search('HeaQueen\'s d'), []) ) }) suite('create with strict=true, minLength=4:', () => { let index setup(() => { index = surch.create('foo', { strict: true, minLength: 4 }) index.add({ _id: 0, foo: 'The quick brown fox jumps over the lazy dog.' }) }) test('search with whitespace-separated match returns correct result', () => assert.deepEqual(index.search('the l'), [ { id: 0, match: 'The quick brown fox jumps over the lazy dog.', indices: [ 31 ], score: 11 } ]) ) }) suite('create with fuzzy=true:', () => { let index setup(() => { index = surch.create('wibble', { fuzzy: true }) index.add({ _id: 0, wibble: 'The King, Queen & Bishop' }) index.add({ _id: 1, wibble: 'The Queen\'s Head' }) index.add({ _id: 2, wibble: 'The King\'s Arms' }) }) test('search with exact match returns correct results', () => assert.deepEqual(index.search('Queen'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 4, 5, 6 ], score: 31 }, { id: 0, match: 'The King, Queen & Bishop', indices: [ 10, 11, 12 ], score: 21 }, ]) ) test('search with fuzzy match returns correct result', () => { assert.deepEqual(index.search('qeen'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 6 ], score: 19 }, { id: 0, match: 'The King, Queen & Bishop', indices: [ 12 ], score: 13 }, ]) assert.deepEqual(index.search('ead'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 13 ], score: 19 }, ]) assert.deepEqual(index.search('heaeen'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 12, 6 ], score: 38 }, { id: 0, match: 'The King, Queen & Bishop', indices: [ 12 ], score: 21 }, ]) assert.deepEqual(index.search('urgle que'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 4 ], score: 31 }, { id: 0, match: 'The King, Queen & Bishop', indices: [ 10 ], score: 25 }, ]) }) test('search with wrong order returns correct result', () => assert.deepEqual(index.search('HeaQueen\'s d'), [ { id: 1, match: 'The Queen\'s Head', indices: [ 12, 4, 5, 6, 7 ], score: 75 }, { id: 0, match: 'The King, Queen & Bishop', indices: [ 10, 11, 12 ], score: 33 }, ]) ) }) suite('readme:', () => { let index setup(() => { index = surch.create('foo') index.add({ _id: 'ffox1', foo: 'Down in the valley there were three farms.' }) index.add({ _id: 'ffox2', foo: 'The owners of these farms had done well.' }) index.add({ _id: 'ffox3', foo: 'They were rich men.' }) }) test('search with 2 matches returns correct results', () => assert.deepEqual(index.search('farm'), [ { id: 'ffox2', match: 'The owners of these farms had done well.', indices: [ 20 ], score: 10 }, { id: 'ffox1', match: 'Down in the valley there were three farms.', indices: [ 36 ], score: 10 } ]) ) test('search with 1 match returns correct result', () => assert.deepEqual(index.search('valle far'), [ { id: 'ffox1', match: 'Down in the valley there were three farms.', indices: [ 12, 36 ], score: 21 } ]) ) }) suite('unicode high-order bytes:', () => { let index setup(() => { index = surch.create('foo') index.add({ _id: 'bar', foo: '💩💰💥 🔥😞😀' }) index.add({ _id: 'baz', foo: '💩💰💥 🔥😞🙀' }) }) test('search with 2 matches returns correct results', () => assert.deepEqual(index.search('💩💰💥'), [ { id: 'bar', match: '💩💰💥 🔥😞😀', indices: [ 0 ], score: 43 }, { id: 'baz', match: '💩💰💥 🔥😞🙀', indices: [ 0 ], score: 43 } ]) ) test('search with 1 match returns correct result', () => assert.deepEqual(index.search('🔥😞😀'), [ { id: 'bar', match: '💩💰💥 🔥😞😀', indices: [ 4 ], score: 43 } ]) ) test('search with no matches returns empty result', () => assert.deepEqual(index.search('🔥😞🚀'), []) ) test('search throws with short query', () => assert.throws(() => index.search('🔥😞')) ) }) suite('unicode lookalikes:', () => { let index setup(() => { index = surch.create('foo') index.add({ _id: 'bar', foo: 'ma\xf1ana' }) index.add({ _id: 'baz', foo: 'man\u0303ana' }) }) test('search with normalised query returns correct results', () => assert.deepEqual(index.search('ma\xf1ana'), [ { id: 'bar', match: 'ma\xf1ana', indices: [ 0 ], score: 100 }, { id: 'baz', match: 'man\u0303ana', indices: [ 0 ], score: 100 } ]) ) test('search with unnormalised query returns correct results', () => assert.deepEqual(index.search('man\u0303ana'), [ { id: 'bar', match: 'ma\xf1ana', indices: [ 0 ], score: 100 }, { id: 'baz', match: 'man\u0303ana', indices: [ 0 ], score: 100 } ]) ) }) suite('id coercion:', () => { let index setup(() => { index = surch.create('foo', { coerceId: id => id.str }) index.add({ _id: { str: 'bar' }, foo: 'qux' }) index.add({ _id: { str: 'baz' }, foo: 'qux' }) }) test('add throws with duplicate coerced id', () => assert.throws(() => index.add({ _id: { str: 'bar' }, foo: 'wibble' })) ) test('update recognises coerced id', () => { assert.deepEqual(index.search('qux'), [ { id: 'bar', match: 'qux', indices: [ 0 ], score: 100 }, { id: 'baz', match: 'qux', indices: [ 0 ], score: 100 } ]) index.update({ _id: { str: 'bar' }, foo: 'wibble' }) assert.deepEqual(index.search('qux'), [ { id: 'baz', match: 'qux', indices: [ 0 ], score: 100 } ]) }) test('delete recognises coerced id', () => { assert.deepEqual(index.search('qux'), [ { id: 'bar', match: 'qux', indices: [ 0 ], score: 100 }, { id: 'baz', match: 'qux', indices: [ 0 ], score: 100 } ]) index.delete({ str: 'baz' }) assert.deepEqual(index.search('qux'), [ { id: 'bar', match: 'qux', indices: [ 0 ], score: 100 } ]) }) }) suite('https://gitlab.com/philbooth/surch/issues/1:', () => { let index setup(() => { index = surch.create('foo') index.add({ _id: '01', foo: 'The Queen\'s Head' }) index.add({ _id: '02', foo: 'The Craft Beer Co.' }) index.add({ _id: '03', foo: 'The Three Johns' }) }) test('search returns correct results', () => { assert.deepEqual(index.search('The Craft Beer Co.'), [ { id: '02', match: 'The Craft Beer Co.', indices: [ 0, 4, 10 ], score: 100 } ]) assert.deepEqual(index.search('The Three Johns'), [ { id: '03', match: 'The Three Johns', indices: [ 0, 4, 10 ], score: 100 } ]) }) }) suite('https://gitlab.com/philbooth/surch/issues/2:', () => { let index setup(() => { index = surch.create('foo') index.add({ _id: '01', foo: 'bar baz' }) index.add({ _id: '02', foo: 'bar baz' }) }) test('search returns correct results', () => assert.deepEqual(index.search('bar baz'), [ { id: '01', match: 'bar baz', indices: [ 0, 4 ], score: 100 }, { id: '02', match: 'bar baz', indices: [ 0, 4 ], score: 100 } ]) ) }) suite('https://gitlab.com/philbooth/surch/issues/3:', () => { let index setup(() => { index = surch.create('foo') index.add({ _id: '01', foo: 'foo bara' }) index.add({ _id: '02', foo: 'foo barb' }) }) test('search returns correct results', () => { assert.deepEqual(index.search('foo'), [ { id: '01', match: 'foo bara', indices: [ 0 ], score: 38 }, { id: '02', match: 'foo barb', indices: [ 0 ], score: 38 } ]) assert.deepEqual(index.search('foo bara'), [ { id: '01', match: 'foo bara', indices: [ 0, 4 ], score: 100 } ]) assert.deepEqual(index.search('foo barb'), [ { id: '02', match: 'foo barb', indices: [ 0, 4 ], score: 100 } ]) }) })