fix(dictionary): normalize lookup query with trim + case fallback (#4192)
A double-click selection can carry trailing whitespace and most imported dictionaries store headwords lowercased, so an exact match on the raw selection often misses (e.g. `Hello` or `world ` fail to resolve `hello`/`world`). Case-sensitive formats like mdict are hit hardest since their reader compares the raw word. Seed the lookup history with a trimmed word and try ordered query variants (trimmed, lowercase, title-case, uppercase) per provider, keeping the first hit. Closes #4176. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -195,6 +195,23 @@ const buildEmptyProvider = (id: string, label: string): DictionaryProvider => ({
|
||||
},
|
||||
});
|
||||
|
||||
// A case-sensitive, whitespace-sensitive provider that only resolves the
|
||||
// exact stored headword — mimics mdict's lookup path. Used to assert the
|
||||
// query-normalization fallback (trim + case folding).
|
||||
const buildExactProvider = (storedHeadword: string): DictionaryProvider => {
|
||||
const provider: DictionaryProvider = {
|
||||
id: 'exact',
|
||||
kind: 'mdict',
|
||||
label: 'Exact Match',
|
||||
async lookup(word, ctx) {
|
||||
if (word !== storedHeadword) return { ok: false, reason: 'empty' };
|
||||
ctx.container.append(document.createTextNode(`def for ${word}`));
|
||||
return { ok: true, headword: word, sourceLabel: 'Exact Match' };
|
||||
},
|
||||
};
|
||||
return provider;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sheet harness
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -293,6 +310,45 @@ describe('DictionarySheet — concurrent lookup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DictionarySheet — query normalization', () => {
|
||||
it('resolves a lowercase-stored entry from a capitalized selection', async () => {
|
||||
const exact = buildExactProvider('hello');
|
||||
const spy = vi.spyOn(exact, 'lookup');
|
||||
providersForNextRender.push(exact);
|
||||
renderSheet({ word: 'Hello' });
|
||||
|
||||
await screen.findByText('Exact Match');
|
||||
// First probe is the selection as-is; the lowercase fallback hits.
|
||||
expect(spy).toHaveBeenCalledWith('Hello', expect.any(Object));
|
||||
expect(spy).toHaveBeenCalledWith('hello', expect.any(Object));
|
||||
});
|
||||
|
||||
it('resolves an entry from a selection with trailing whitespace', async () => {
|
||||
providersForNextRender.push(buildExactProvider('world'));
|
||||
renderSheet({ word: 'world ' });
|
||||
|
||||
await screen.findByText('Exact Match');
|
||||
});
|
||||
|
||||
it('trims trailing whitespace from the displayed title', async () => {
|
||||
providersForNextRender.push(buildExactProvider('world'));
|
||||
renderSheet({ word: 'world ' });
|
||||
|
||||
expect((await screen.findByTestId('dict-title')).textContent).toBe('world');
|
||||
});
|
||||
|
||||
it('stops at the first matching variant without probing the rest', async () => {
|
||||
const exact = buildExactProvider('hello');
|
||||
const spy = vi.spyOn(exact, 'lookup');
|
||||
providersForNextRender.push(exact);
|
||||
renderSheet({ word: 'hello' });
|
||||
|
||||
await screen.findByText('Exact Match');
|
||||
// 'hello' hits immediately — no title-case / upper-case probes follow.
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DictionarySheet — expand / collapse', () => {
|
||||
it('toggles aria-expanded when a card is tapped', async () => {
|
||||
providersForNextRender.push(buildRealStarDictProvider());
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { buildLookupCandidates } from '@/services/dictionaries/lookupCandidates';
|
||||
|
||||
describe('buildLookupCandidates', () => {
|
||||
it('returns the lowercase word for an already-lowercase selection', () => {
|
||||
expect(buildLookupCandidates('hello')).toEqual(['hello', 'Hello', 'HELLO']);
|
||||
});
|
||||
|
||||
it('trims leading/trailing whitespace from a double-click selection', () => {
|
||||
expect(buildLookupCandidates('world ')).toEqual(['world', 'World', 'WORLD']);
|
||||
expect(buildLookupCandidates(' spaced ')).toEqual(['spaced', 'Spaced', 'SPACED']);
|
||||
});
|
||||
|
||||
it('offers a lowercase variant for a sentence-initial capitalized word', () => {
|
||||
expect(buildLookupCandidates('Hello')).toEqual(['Hello', 'hello', 'HELLO']);
|
||||
});
|
||||
|
||||
it('offers lowercase and title-case variants for an all-caps selection', () => {
|
||||
expect(buildLookupCandidates('HELLO')).toEqual(['HELLO', 'hello', 'Hello']);
|
||||
});
|
||||
|
||||
it('de-duplicates collapsed variants', () => {
|
||||
// A single lowercase letter collapses to one unique candidate.
|
||||
expect(buildLookupCandidates('a')).toEqual(['a', 'A']);
|
||||
});
|
||||
|
||||
it('returns an empty list for a blank selection', () => {
|
||||
expect(buildLookupCandidates('')).toEqual([]);
|
||||
expect(buildLookupCandidates(' ')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
|
||||
import { getEnabledProviders } from '@/services/dictionaries/registry';
|
||||
import { buildLookupCandidates } from '@/services/dictionaries/lookupCandidates';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import {
|
||||
getBuiltinWebSearch,
|
||||
@@ -84,13 +85,14 @@ export function useDictionaryResults({
|
||||
const definitionProviders = useMemo(() => providers.filter((p) => p.kind !== 'web'), [providers]);
|
||||
const webSearchProviders = useMemo(() => providers.filter((p) => p.kind === 'web'), [providers]);
|
||||
|
||||
const [historyStack, setHistoryStack] = useState<string[]>([word]);
|
||||
const currentWord = historyStack[historyStack.length - 1] ?? word;
|
||||
const [historyStack, setHistoryStack] = useState<string[]>([word.trim()]);
|
||||
const currentWord = historyStack[historyStack.length - 1] ?? word.trim();
|
||||
|
||||
// Reset the history when the host reopens with a new word from outside
|
||||
// (selection change in the reader).
|
||||
// (selection change in the reader). A double-click selection can carry
|
||||
// trailing whitespace, so trim before seeding.
|
||||
useEffect(() => {
|
||||
setHistoryStack([word]);
|
||||
setHistoryStack([word.trim()]);
|
||||
}, [word]);
|
||||
|
||||
const [cards, setCards] = useState<Record<string, CardState>>({});
|
||||
@@ -209,16 +211,25 @@ export function useDictionaryResults({
|
||||
if (!container) {
|
||||
outcome = { ok: false, reason: 'error', message: 'no container' };
|
||||
} else {
|
||||
container.replaceChildren();
|
||||
outcome = await provider.lookup(currentWord, {
|
||||
lang: langCode,
|
||||
signal: controller.signal,
|
||||
container,
|
||||
onNavigate: pushWord,
|
||||
isDarkMode,
|
||||
bg: themeCode.bg,
|
||||
fg: themeCode.fg,
|
||||
});
|
||||
// Try normalized query variants (trimmed, case-folded) in
|
||||
// priority order and keep the first hit. Case-sensitive
|
||||
// formats (mdict) otherwise miss `Hello` / `world ` style
|
||||
// selections whose headword is stored lowercased.
|
||||
outcome = { ok: false, reason: 'empty' };
|
||||
for (const candidate of buildLookupCandidates(currentWord)) {
|
||||
container.replaceChildren();
|
||||
outcome = await provider.lookup(candidate, {
|
||||
lang: langCode,
|
||||
signal: controller.signal,
|
||||
container,
|
||||
onNavigate: pushWord,
|
||||
isDarkMode,
|
||||
bg: themeCode.bg,
|
||||
fg: themeCode.fg,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
if (outcome.ok || outcome.reason !== 'empty') break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
outcome = {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Ordered, de-duplicated query variants for a dictionary lookup.
|
||||
*
|
||||
* A double-click selection in the reader can carry leading/trailing
|
||||
* whitespace, and most imported dictionaries store headwords lowercased, so
|
||||
* an exact match on the raw selection often misses — e.g. `Hello` or
|
||||
* `world ` fail to resolve `hello`/`world`. The DICT/StarDict/slob readers
|
||||
* already compare case-insensitively, but case-sensitive formats (mdict) do
|
||||
* not. Callers try each candidate in order and keep the first hit.
|
||||
*
|
||||
* Variants, in priority order: the trimmed selection as-is, all-lowercase,
|
||||
* title-case, all-uppercase (for acronym headwords). Returns `[]` for a
|
||||
* blank input.
|
||||
*/
|
||||
export const buildLookupCandidates = (word: string): string[] => {
|
||||
const trimmed = word.trim();
|
||||
if (!trimmed) return [];
|
||||
const lower = trimmed.toLowerCase();
|
||||
const title = trimmed.charAt(0).toUpperCase() + lower.slice(1);
|
||||
const upper = trimmed.toUpperCase();
|
||||
return [...new Set([trimmed, lower, title, upper])];
|
||||
};
|
||||
Reference in New Issue
Block a user