Phase 21: Petal learns to be an English+Portuguese pair
The plan said "Hunspell pt-PT vendored like en-US". Measuring that first is what saved it: nspell expands affixes eagerly on construction, and European Portuguese's 1,340 rules over 44,257 stems want over a gigabyte of browser heap — ~340 MB for the first 12,000 entries, and no return at all after three minutes on the whole file. So the expansion runs once at build time instead: 1,039,058 forms, 2.66 MB gzipped, read by the same nspell in 842 ms. The obvious npm package would also have shipped the wrong language. Both dictionary-pt and dictionary-pt-br carry VERO, the Brazilian word list, so vendoring by name puts pt-BR spellings behind a pt-PT label — the drift SUGGESTIONS §3 warns about, arriving through the packaging where no reviewer can see it. The source is Projecto Natura's, and the build script now asserts the fault lines (receção in, recepção out) before writing anything. Spellcheck consults both dictionaries and flags only what both reject, which is the no-detector answer to a pair with no script boundary. The word card does the same in the other direction: "data" is a word in both languages, so Petal shows both readings rather than guessing which she meant. Writing the tests caught the one real bug — extendedAlphabet was a snapshot while correct/suggest read live, and her dictionary arrives after English, so every lookup would have resolved "cora" while the underlines were already right. Not done, and not claimed: the pack has not been read by a pt-PT speaker, and the Piper voice is deferred with the deploy. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { combine, interleave, type Loaded } from './useSpellChecker'
|
||||
|
||||
// The both-dictionaries rule (SUGGESTIONS.md §3a), separated from the fetching
|
||||
// so it can be checked without a 15 MB word list. What matters here is not
|
||||
// "does nspell work" but which way the combination is allowed to be wrong.
|
||||
|
||||
// A dictionary that accepts exactly the words it was given.
|
||||
function dict(lang: string, words: string[], corrections: string[] = [], extendedAlphabet = false): Loaded {
|
||||
const set = new Set(words)
|
||||
return {
|
||||
lang,
|
||||
extendedAlphabet,
|
||||
spell: {
|
||||
correct: (w: string) => set.has(w),
|
||||
suggest: () => corrections,
|
||||
add: (w: string) => set.add(w),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const en = dict('en', ['sale', 'the', 'river'], ['sailed', 'salt'])
|
||||
const pt = dict('pt-PT', ['sale', 'coração', 'jardim'], ['salte', 'sala'], true)
|
||||
|
||||
describe('the both-dictionaries rule', () => {
|
||||
it('accepts a word either dictionary knows', () => {
|
||||
const c = combine(() => [en, pt])
|
||||
expect(c.correct('river')).toBe(true) // English only
|
||||
expect(c.correct('jardim')).toBe(true) // Portuguese only
|
||||
expect(c.correct('sale')).toBe(true) // both — the collision case
|
||||
})
|
||||
|
||||
it('flags only what every dictionary rejects', () => {
|
||||
expect(combine(() => [en, pt]).correct('qqzzx')).toBe(false)
|
||||
})
|
||||
|
||||
it('never flags a Portuguese word just because English has not heard of it', () => {
|
||||
// The property the whole design exists for. With English alone, "coração"
|
||||
// is a misspelling; with her own dictionary loaded it is a word she wrote.
|
||||
expect(combine(() => [en]).correct('coração')).toBe(false)
|
||||
expect(combine(() => [en, pt]).correct('coração')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts everything when no dictionary loaded', () => {
|
||||
// A failed fetch must not underline every word in the document. Silence is
|
||||
// the safe failure; a page of red is not.
|
||||
expect(combine(() => []).correct('qqzzx')).toBe(true)
|
||||
})
|
||||
|
||||
it('sees a dictionary that arrives after the checker was built', () => {
|
||||
// English loads immediately; hers lands a moment later, once /api/me has
|
||||
// named her pair. The checker reads through a getter for exactly this.
|
||||
let loaded: Loaded[] = [en]
|
||||
const c = combine(() => loaded)
|
||||
expect(c.correct('jardim')).toBe(false)
|
||||
expect(c.extendedAlphabet).toBe(false)
|
||||
loaded = [en, pt]
|
||||
expect(c.correct('jardim')).toBe(true)
|
||||
expect(c.extendedAlphabet).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('correction pills', () => {
|
||||
it('interleaves the two dictionaries rather than letting one fill the list', () => {
|
||||
// Five pills fit. Concatenating would spend all of them on English and
|
||||
// leave a misspelt Portuguese word with no Portuguese correction — the one
|
||||
// case the second dictionary was loaded for.
|
||||
expect(combine(() => [en, pt]).suggest('salle')).toEqual(['sailed', 'salte', 'salt', 'sala'])
|
||||
})
|
||||
|
||||
it('drops duplicates, keeping the first dictionary to offer one', () => {
|
||||
expect(interleave([['a', 'b'], ['a', 'c']])).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('keeps going when one dictionary runs out of ideas', () => {
|
||||
expect(interleave([['a'], ['x', 'y', 'z']])).toEqual(['a', 'x', 'y', 'z'])
|
||||
expect(interleave([[], []])).toEqual([])
|
||||
expect(interleave([])).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user