From 07a83b2e3db40f4a380092d6566500799b340dcd Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Fri, 21 Aug 2026 16:13:12 +0200 Subject: feat: fuzzy autocomplete for add-item input Replace the native with a custom fuzzysort-driven component (FuzzyAutocomplete) with keyboard nav and highlighted matches, preserving exact-match auto-add. Extract a unit-tested fuzzy-search helper, and gitignore .juggler/. Fix the dev DB boot failure caused by a sibling project's ambient DATABASE_URL leaking in by overriding it (file:./data.db) in mise.toml. --- src/lib/components/fuzzy-search.test.ts | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/lib/components/fuzzy-search.test.ts (limited to 'src/lib/components/fuzzy-search.test.ts') diff --git a/src/lib/components/fuzzy-search.test.ts b/src/lib/components/fuzzy-search.test.ts new file mode 100644 index 0000000..57d0d1d --- /dev/null +++ b/src/lib/components/fuzzy-search.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { matchItems, FUZZ_LIMIT } from './fuzzy-search'; +import type { FuzzyItem } from './fuzzy-search'; + +const catalog: FuzzyItem[] = [ + { id: 1, name: 'Citron' }, + { id: 2, name: 'Citrons' }, + { id: 3, name: 'Concombre' }, + { id: 4, name: 'Tomate' }, + { id: 5, name: 'Carotte' }, + { id: 6, name: 'Lait' }, +]; + +describe('matchItems', () => { + it('returns nothing for an empty or whitespace query', () => { + expect(matchItems(catalog, '')).toHaveLength(0); + expect(matchItems(catalog, ' ')).toHaveLength(0); + }); + + it('matches a fuzzy query, ranking the best match first', () => { + const res = matchItems(catalog, 'citrn'); + expect(res.length).toBeGreaterThanOrEqual(1); + // exact-ish run should beat the pluralised variant + expect(res[0].obj.name).toBe('Citron'); + }); + + it('is case-insensitive', () => { + const res = matchItems(catalog, 'TOMA'); + expect(res[0].obj.name).toBe('Tomate'); + }); + + it('keeps matched-across-junk subsequence results', () => { + const res = matchItems(catalog, 'cb'); // C-o-n-**c**ombre → c arguments + const names = res.map((r) => r.obj.name); + expect(names).toContain('Concombre'); + }); + + it('respects the limit', () => { + const many = Array.from({ length: 40 }, (_, i) => ({ + id: i, + name: `Article ${i}`, + })); + const res = matchItems(many, 'article'); + expect(res.length).toBeLessThanOrEqual(FUZZ_LIMIT); + }); + + it('sorts best matches first by descending score', () => { + const res = matchItems(catalog, 'tomate'); + for (let i = 1; i < res.length; i++) { + expect(res[i - 1].score).toBeGreaterThanOrEqual(res[i].score); + } + }); +}); -- cgit v1.3