import test from 'node:test'; import assert from 'node:assert/strict'; import { LatestLane, AutomaticIndexGate, emptySearchState, semanticAvailable, SEMANTIC_SETTLE_MS, hasSearchInput, scopedFilters } from '../src/search-state.ts'; const settle = () => new Promise(resolve => setImmediate(resolve)); test('coalesces queued keystrokes and discards stale responses', async () => { let revision = 1; let release; const executed = []; const accepted = []; const lane = new LatestLane(async value => { executed.push(value); if (value === 1) await new Promise(resolve => { release = resolve; }); if (value === revision) accepted.push(value); }, value => value === revision, () => assert.fail('Unexpected failure')); lane.submit(1); revision = 2; lane.submit(2); revision = 3; lane.submit(3); release(); await settle(); assert.deepEqual(executed, [1, 3]); assert.deepEqual(accepted, [3]); }); test('Escape invalidates both active and queued work', async () => { let revision = 1; let release; const accepted = []; const lane = new LatestLane(async value => { await new Promise(resolve => { release = resolve; }); if (value === revision) accepted.push(value); }, value => value === revision, () => assert.fail('Unexpected failure')); lane.submit(1); lane.submit(1); revision++; lane.clear(); release(); await settle(); assert.deepEqual(accepted, []); }); test('busy retries are bounded and stop when superseded', async () => { let attempts = 0; let errors = 0; const lane = new LatestLane(async () => { attempts++; throw new Error('worker busy'); }, () => true, () => errors++, async () => {}); lane.submit(1); await settle(); assert.equal(attempts, 2); assert.equal(errors, 1); let revision = 1; let release; const executed = []; const superseded = new LatestLane(async value => { executed.push(value); if (value === 1) throw new Error('busy'); }, value => value === revision, () => assert.fail('Unexpected failure'), () => new Promise(resolve => { release = resolve; })); superseded.submit(1); await settle(); revision = 2; superseded.submit(2); release(); await settle(); assert.deepEqual(executed, [1, 2]); }); test('automatic rebuild failures wait for a changed revision', () => { const gate = new AutomaticIndexGate(); assert.equal(gate.claim('generation1:pending4:folder1'), true); assert.equal(gate.claim('generation1:pending4:folder1'), false); assert.equal(gate.claim('generation1:pending4:folder2'), true); }); test('empty keyword results stay provisional while semantic setup or search is pending', () => { for (const phase of ['preparing', 'indexing', 'pending']) { const state = emptySearchState('lexical', phase); assert.equal(state.title, 'No keyword matches yet.'); assert.match(state.detail, /preparing|indexing|Searching by meaning/); assert.doesNotMatch(state.title, /No matching files|No matches for this search/); } }); test('only a completed hybrid search gives a final no-match message', () => { const complete = emptySearchState('hybrid', 'complete'); assert.equal(complete.title, 'No matches for this search.'); assert.match(complete.detail, /filters/); const rebuilding = emptySearchState('hybrid', 'indexing'); assert.equal(rebuilding.title, 'No matches in the current index yet.'); assert.match(rebuilding.detail, /may change/); for (const phase of ['preparing', 'indexing', 'pending', 'unavailable', 'no-content']) { assert.notEqual(emptySearchState('hybrid', phase).title, complete.title); } }); test('no document content and unavailable semantics have actionable distinct guidance', () => { assert.match(emptySearchState('lexical', 'no-content').detail, /readable documents/); assert.match(emptySearchState('lexical', 'unavailable').detail, /retry the search/); }); test('old committed semantic generation stays available during rebuild, activation readiness gates replacement', () => { assert.equal(semanticAvailable(null), false); assert.equal(semanticAvailable({ready: true, generation: null, embedding: true}), false); assert.equal(semanticAvailable({ready: true, generation: 1, embedding: false}), true); assert.equal(semanticAvailable({ready: true, generation: 1, embedding: true}), true); assert.equal(semanticAvailable({ready: false, generation: 2, embedding: true}), false); assert.equal(semanticAvailable({ready: true, generation: 2, embedding: false}), true); assert.equal(SEMANTIC_SETTLE_MS, 120); }); test('scope snapshots freeze all filters independently of later control changes', () => { const controls = { extension: 'rs', folder: '/project' }; const all = scopedFilters('all', controls); const code = scopedFilters('code', controls); controls.extension = 'pdf'; assert.equal(all.code_only, false); assert.equal(code.code_only, true); assert.equal(code.extension, 'rs'); assert.equal(code.folder, '/project'); assert.throws(() => { code.code_only = false; }, TypeError); assert.throws(() => scopedFilters('unknown', {}), /scope/); }); test('same-query scope change discards old lane output and runs latest scoped request', async () => { let revision = 1; let release; const seen = []; const accepted = []; const lane = new LatestLane(async request => { seen.push(request.filters.code_only); if (request.id === 1) await new Promise(resolve => { release = resolve; }); if (request.id === revision) accepted.push(request); }, request => request.id === revision, () => assert.fail('Unexpected failure')); lane.submit({id: 1, query: 'parse configuration', filters: scopedFilters('all', {})}); revision = 2; lane.submit({id: 2, query: 'parse configuration', filters: scopedFilters('code', {extension: 'rs'})}); release(); await settle(); assert.deepEqual(seen, [false, true]); assert.equal(accepted.length, 1); assert.equal(accepted[0].filters.code_only, true); assert.equal(accepted[0].filters.extension, 'rs'); }); test('exact music fields allow metadata-only searches without general filter browsing', () => { assert.equal(hasSearchInput('', scopedFilters('all', {media_artist: ' Artist '})), true); assert.equal(hasSearchInput('', scopedFilters('all', {media_title: 'Song'})), true); assert.equal(hasSearchInput(' ', scopedFilters('all', {media_artist: ' ', extension: 'mp3'})), false); assert.equal(hasSearchInput('violin', scopedFilters('all', {})), true); });