fix(reader): resolve non-English dictionary lookups

Use English Wiktionary's multilingual definitions endpoint to avoid HTTP 501
responses from language-specific hosts. Keep filtering definitions by the
book's language and cover host selection, language aliases, inflection
lookups, URL encoding, and popover retry recovery with regression tests.

Closes #1439
This commit is contained in:
neon
2026-10-12 16:56:34 +00:00
parent b86502fcd6
commit 2b89c30a8d
3 changed files with 136 additions and 9 deletions
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { i18n } from '@/i18n'
import DictionaryPopover from '../DictionaryPopover.vue'
enableAutoUnmount(afterEach)
const definitionUrl = 'https://en.wiktionary.org/api/rest_v1/page/definition/nada'
const payload = {
en: [{ partOfSpeech: 'Pronoun', definitions: [{ definition: 'English sense.' }] }],
pt: [{ partOfSpeech: 'Pronoun', definitions: [{ definition: '<b>nothing</b>', examples: ['Não há nada.'] }] }],
}
function mountPopover(lang: string) {
return mount(DictionaryPopover, {
props: { word: 'nada', lang, position: { x: 200, y: 300, showBelow: false } },
global: { stubs: { teleport: true } },
})
}
describe('DictionaryPopover with the actual dictionary lookup', () => {
const fetchMock = vi.fn<typeof fetch>()
let previousLocale: typeof i18n.global.locale.value
beforeEach(() => {
previousLocale = i18n.global.locale.value
i18n.global.locale.value = 'en'
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
i18n.global.locale.value = previousLocale
vi.unstubAllGlobals()
})
it.each(['pt', 'pt-BR'])('renders Portuguese definitions for %s instead of the unsupported-endpoint error', async (lang) => {
fetchMock.mockImplementation(async (input) =>
String(input) === definitionUrl ? new Response(JSON.stringify(payload)) : new Response('{}', { status: 501 }),
)
const wrapper = mountPopover(lang)
await flushPromises()
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(definitionUrl)
expect(wrapper.text()).toContain('nothing')
expect(wrapper.text()).not.toContain('English sense.')
expect(wrapper.text()).not.toContain('Could not load definition')
expect(wrapper.find('button').exists()).toBe(false)
})
it('handles an upstream 501 and recovers on Retry using the same language', async () => {
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 501 })).mockResolvedValueOnce(new Response(JSON.stringify(payload)))
const wrapper = mountPopover('pt-BR')
await flushPromises()
expect(wrapper.text()).toContain('Could not load definition')
expect(wrapper.find('button').text()).toBe('Retry')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([definitionUrl, definitionUrl])
expect(wrapper.text()).toContain('nothing')
expect(wrapper.text()).not.toContain('English sense.')
expect(wrapper.text()).not.toContain('Could not load definition')
expect(wrapper.find('button').exists()).toBe(false)
})
})
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const FREE_DICT_URL = 'https://api.dictionaryapi.dev/api/v2/entries/en/hello'
const WIKTIONARY_EN_URL = 'https://en.wiktionary.org/api/rest_v1/page/definition/hello'
const WIKTIONARY_FR_URL = 'https://fr.wiktionary.org/api/rest_v1/page/definition/bonjour'
const WIKTIONARY_FR_URL = 'https://en.wiktionary.org/api/rest_v1/page/definition/bonjour'
function makeFreeDictResponse(word = 'hello', overrides: Record<string, unknown> = {}) {
return [
@@ -86,6 +86,7 @@ describe('useDictionary', () => {
/** Serves a fixed set of Wiktionary pages by word, 404ing anything else. */
function serveWiktionary(pages: Record<string, unknown>) {
fetchMock.mockImplementation(async (input) => {
if (new URL(String(input)).hostname !== 'en.wiktionary.org') return mockStatus(501)
const word = decodeURIComponent(String(input).split('/definition/')[1] ?? '')
return word in pages ? mockOk(pages[word]) : mockStatus(404)
})
@@ -289,6 +290,66 @@ describe('useDictionary', () => {
expect(result!.entries.length).toBeGreaterThan(0)
})
it.each(['pt', 'pt-BR', 'pt-PT', 'por', 'Portuguese', ' PT-br '])(
'Non-English: resolves Portuguese metadata %s through the English host and only the pt section',
async (lang) => {
serveWiktionary({
nada: {
en: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'English sense.' }] }],
es: [{ partOfSpeech: 'Pronoun', definitions: [{ definition: 'Spanish sense.' }] }],
pt: [
{ partOfSpeech: 'Pronoun', definitions: [{ definition: '<a href="/wiki/nothing">nothing</a>', examples: ['Não há <b>nada</b> aqui.'] }] },
],
},
})
const { lookup } = await load()
expect(await lookup('nada', lang)).toEqual({
word: 'nada',
phonetic: null,
audioUrl: null,
provider: 'wiktionary',
entries: [{ partOfSpeech: 'Pronoun', sourceWord: 'nada', definitions: [{ definition: 'nothing', example: 'Não há nada aqui.' }] }],
})
expect(fetchMock).toHaveBeenCalledExactlyOnceWith('https://en.wiktionary.org/api/rest_v1/page/definition/nada')
},
)
it('Non-English: follows inflection pointers on the English host while preserving the requested language', async () => {
serveWiktionary({
gatos: {
en: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'Unrelated English sense.' }] }],
pt: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'plural of gato' }] }],
},
gato: {
es: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'Unrelated Spanish sense.' }] }],
pt: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'A cat.' }] }],
},
})
const { lookup } = await load()
const result = await lookup('gatos', 'pt-BR')
expect(result!.entries).toEqual([
{ partOfSpeech: 'Noun', sourceWord: 'gatos', definitions: [{ definition: 'plural of gato', example: null }] },
{ partOfSpeech: 'Noun', sourceWord: 'gato', definitions: [{ definition: 'A cat.', example: null }] },
])
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'https://en.wiktionary.org/api/rest_v1/page/definition/gatos',
'https://en.wiktionary.org/api/rest_v1/page/definition/gato',
])
})
it('Non-English: trims and encodes Unicode and URL delimiters in the word', async () => {
const word = 'coração/ação?#'
serveWiktionary({ [word]: { pt: [{ partOfSpeech: 'Noun', definitions: [{ definition: 'A Portuguese sense.' }] }] } })
const { lookup } = await load()
expect((await lookup(` ${word} `, 'pt'))!.word).toBe(word)
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(
'https://en.wiktionary.org/api/rest_v1/page/definition/cora%C3%A7%C3%A3o%2Fa%C3%A7%C3%A3o%3F%23',
)
})
it('Non-English: returns null when Wiktionary returns 404', async () => {
fetchMock.mockResolvedValueOnce(mockStatus(404))
const { lookup } = await load()
@@ -296,10 +357,10 @@ describe('useDictionary', () => {
expect(result).toBeNull()
})
it('Non-English: throws when Wiktionary returns server error', async () => {
fetchMock.mockResolvedValueOnce(mockStatus(500))
it.each([429, 500, 501])('Non-English: throws when Wiktionary returns HTTP %s', async (status) => {
fetchMock.mockResolvedValueOnce(mockStatus(status))
const { lookup } = await load()
await expect(lookup('xyz', 'de')).rejects.toThrow('Wiktionary API error: 500')
await expect(lookup('xyz', 'de')).rejects.toThrow(`Wiktionary API error: ${status}`)
})
// -------------------------------------------------------------------------
@@ -537,9 +598,6 @@ describe('useDictionary', () => {
})
it('pointer: leaves non-English inflection glosses alone', async () => {
// fr.wiktionary writes its glosses in French ("pluriel de chat"). Pointer
// detection is deliberately English-only, so these are shown as-is rather
// than guessed at. Non-English lookups still benefit from language filtering.
fetchMock.mockResolvedValueOnce(mockOk({ fr: [{ partOfSpeech: 'Nom', definitions: [{ definition: 'pluriel de chat', examples: [] }] }] }))
const { lookup } = await load()
const result = await lookup('chats', 'fr')
@@ -943,7 +1001,7 @@ describe('useDictionary', () => {
fetchMock.mockResolvedValueOnce(mockOk(makeWiktionaryResponse('fr')))
const { lookup } = await load()
await lookup('bonjour', 'fr-FR')
expect(fetchMock.mock.calls[0]![0]).toContain('fr.wiktionary.org')
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(WIKTIONARY_FR_URL)
})
it('lang normalization: empty string defaults to English (Free Dictionary)', async () => {
@@ -388,7 +388,8 @@ function parseWiktionaryBlocks(data: Record<string, unknown>, lang: string): Sen
}
async function fetchWiktionaryPayload(word: string, lang: string): Promise<ProviderPayload | null> {
const res = await fetch(`https://${encodeURIComponent(lang)}.wiktionary.org/api/rest_v1/page/definition/${encodeURIComponent(word)}`)
// Only English Wiktionary serves this endpoint; its response includes other languages.
const res = await fetch(`https://en.wiktionary.org/api/rest_v1/page/definition/${encodeURIComponent(word)}`)
if (res.status === 404) return null
if (!res.ok) throw new Error(`Wiktionary API error: ${res.status}`)