mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
91e7195e0f
* fix(library): scoped live search FTS, race, and multi-server advanced search Scope track_fts live search to active server_id so multi-server libraries no longer show empty or wrong-server hits. Match Navidrome-style any-word prefix matching and GROUP BY artist/album dedupe on track_fts only. Frontend: parallel local vs search3 race (empty waits, 8s network timeout), merge supplemental hits after both settle, debug via Settings → Logging → Debug (frontend_debug_log). Advanced Search FTS subqueries use the same server scope fix. * docs: CHANGELOG and credits for PR #868 live search fix
92 lines
2.6 KiB
TypeScript
92 lines
2.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { SearchResults } from '../../api/subsonicTypes';
|
|
import { raceLiveSearch, type LiveSearchRaceSettled } from './searchRace';
|
|
|
|
const empty: SearchResults = { artists: [], albums: [], songs: [] };
|
|
const localHits: SearchResults = {
|
|
artists: [{ id: 'a1', name: 'Local' }],
|
|
albums: [],
|
|
songs: [],
|
|
};
|
|
const networkHits: SearchResults = {
|
|
artists: [{ id: 'a2', name: 'Network' }],
|
|
albums: [],
|
|
songs: [],
|
|
};
|
|
|
|
describe('raceLiveSearch', () => {
|
|
it('network wins when it returns hits first', async () => {
|
|
const winner = await raceLiveSearch(
|
|
() =>
|
|
new Promise<SearchResults | null>(resolve => {
|
|
setTimeout(() => resolve(localHits), 40);
|
|
}),
|
|
async () => networkHits,
|
|
() => false,
|
|
);
|
|
expect(winner?.source).toBe('network');
|
|
});
|
|
|
|
it('waits for network when local is empty', async () => {
|
|
const winner = await raceLiveSearch(
|
|
async () => empty,
|
|
async () => networkHits,
|
|
() => false,
|
|
);
|
|
expect(winner?.source).toBe('network');
|
|
});
|
|
|
|
it('local wins when network is empty and local has hits', async () => {
|
|
const winner = await raceLiveSearch(
|
|
async () => localHits,
|
|
async () => empty,
|
|
() => false,
|
|
);
|
|
expect(winner?.source).toBe('local');
|
|
});
|
|
|
|
it('waits for local when network is empty', async () => {
|
|
const winner = await raceLiveSearch(
|
|
() =>
|
|
new Promise<SearchResults | null>(resolve => {
|
|
setTimeout(() => resolve(localHits), 30);
|
|
}),
|
|
async () => empty,
|
|
() => false,
|
|
);
|
|
expect(winner?.source).toBe('local');
|
|
});
|
|
|
|
it('does not pick empty local before network returns hits', async () => {
|
|
const winner = await raceLiveSearch(
|
|
async () => empty,
|
|
() =>
|
|
new Promise<SearchResults | null>(resolve => {
|
|
setTimeout(() => resolve(networkHits), 30);
|
|
}),
|
|
() => false,
|
|
);
|
|
expect(winner?.source).toBe('network');
|
|
});
|
|
|
|
it('calls onSettled with both runner timings', async () => {
|
|
let settled: LiveSearchRaceSettled | null = null;
|
|
await raceLiveSearch(
|
|
async () => localHits,
|
|
async () => networkHits,
|
|
() => false,
|
|
meta => {
|
|
settled = meta;
|
|
},
|
|
);
|
|
await new Promise<void>(resolve => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
expect(settled).not.toBeNull();
|
|
expect(settled!.localHits).toBe('1/0/0');
|
|
expect(settled!.networkHits).toBe('1/0/0');
|
|
expect(settled!.localMs).toBeGreaterThanOrEqual(0);
|
|
expect(settled!.networkMs).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|