feat(search): suggestions sans doublons + suggestions locales a la frappe

This commit is contained in:
2026-09-26 13:09:37 -04:00
parent 55c02ac52f
commit 4578f5b887
7 changed files with 206 additions and 25 deletions
+18 -1
View File
@@ -4,7 +4,7 @@ import { firstValueFrom, of } from 'rxjs';
import { Injector, runInInjectionContext } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { SuggestService } from './suggest.service';
import { dedupeSort, mergeGroups, highlightParts } from './suggest.util';
import { dedupeSort, mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions } from './suggest.util';
/**
* Step 15 — Unit tests for query typeahead (parsing / dedup / min-length guard).
@@ -57,6 +57,23 @@ function logOk(msg: string): void {
);
logOk('mergeGroups order + dedup');
// --- Pure helpers: dedupeStrings (whitespace + case variants) ---
assertEqual(
dedupeStrings([' Tutoriel Angular ', 'tutoriel angular', 'TUTORIEL ANGULAR', 'autre', '', ' ']),
['Tutoriel Angular', 'autre'],
'dedupeStrings shows each occurrence only once (first wins)',
);
logOk('dedupeStrings dedup');
// --- Pure helpers: local fallback suggestions while typing ---
assertEqual(buildLocalQuerySuggestions('a'), [], 'no local suggestions below 2 chars');
const local = buildLocalQuerySuggestions('angular', 8);
assert(local.length > 0 && local.length <= 8, 'local suggestions fill the panel while typing');
assertEqual(dedupeStrings(local), local, 'local suggestions contain no duplicates');
assert(!local.some((s) => s.trim().toLowerCase() === 'angular'), 'local suggestions never repeat the raw query');
assert(local.some((s) => s.toLowerCase().includes('angular')), 'local suggestions derive from the typed text');
logOk('buildLocalQuerySuggestions fallback');
// --- Pure helpers: highlight ---
assertEqual(
highlightParts('Tutoriel Angular', 'toriel an'),
+47
View File
@@ -34,6 +34,53 @@ export function dedupeSort(items: string[], q = ''): string[] {
return clean;
}
/**
* Deduplicate a flat string list (case/whitespace-insensitive, first
* occurrence wins). Order is preserved.
*/
export function dedupeStrings(items: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of items || []) {
const t = String(raw ?? '').trim();
if (!t) continue;
const k = normSuggest(t);
if (seen.has(k)) continue;
seen.add(k);
out.push(t);
}
return out;
}
/**
* Local query suggestions built while typing, without any network call.
* Used as a fallback tier when provider suggestions are missing or sparse
* (providers without a suggest API, offline, rate-limited): typing always
* yields suggestions. Output is deduplicated; the raw query itself is never
* included.
*/
export function buildLocalQuerySuggestions(q: string, max = 10): string[] {
const base = String(q ?? '').trim().replace(/\s+/g, ' ');
if (base.length < 2) return [];
const year = new Date().getFullYear();
const candidates = [
`${base} official`,
`${base} live`,
`${base} lyrics`,
`${base} remix`,
`${base} cover`,
`${base} tutorial`,
`${base} review`,
`${base} playlist`,
`${base} best of`,
`${base} full album`,
`${base} ${year}`,
`"${base}"`,
];
const n = Math.max(1, Number(max || 10));
return dedupeStrings(candidates.filter((c) => normSuggest(c) !== normSuggest(base))).slice(0, n);
}
/**
* Flatten provider groups into a single ordered list (registry order),
* deduped case-insensitively. Unknown provider keys go last (insertion order).