import { describe, it, expect } from 'vitest' import { combine, interleave, withElision, type Dictionary, 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([]) }) }) // French elision (Phase 24). The word list is built without the elided forms // because carrying them costs 8.25 MB gzipped instead of 1.19 MB, so the split // happens here instead. What is being checked is not "does French work" but the // same question as everywhere else in this file: which way is it allowed to be // wrong. describe('elision', () => { const FR = ['l', 'd', 'j', 'qu', 'jusqu'] const base: Dictionary = { correct: (w) => ['arbre', 'accord', 'elle', 'ici', "aujourd'hui", 'Étang'].includes(w), suggest: (w) => (w === 'arbrz' ? ['arbre', 'arbres'] : []), add: () => undefined, } const fr = withElision(base, FR) it('looks past a clitic the language actually elides', () => { // The whole reason this exists: every one of these is absent from the built // word list, and every one of them is ordinary French. for (const w of ["l'arbre", "d'accord", "qu'elle", "jusqu'ici"]) { expect(base.correct(w), `${w} should not be in the list`).toBe(false) expect(fr.correct(w), w).toBe(true) } }) it('leaves a word that carries its own apostrophe alone', () => { // "aujourd" is not a clitic, so this is never split — it matches directly, // which is why the build script keeps such stems verbatim. expect(fr.correct("aujourd'hui")).toBe(true) }) it('still flags an apostrophe that is not an elision', () => { // Both halves have to hold up: an unknown clitic on a real word, and a real // clitic on an unknown word. Splitting is a second lookup, not an amnesty. expect(fr.correct("zzz'arbre")).toBe(false) expect(fr.correct("l'zzzz")).toBe(false) }) it('is case-insensitive about the clitic, because a sentence can start with one', () => { expect(fr.correct("L'Étang")).toBe(true) }) it('puts the clitic back on its corrections', () => { // The pill replaces the whole token. Offering "arbre" for "l'arbrz" would // silently delete the article she wrote. expect(fr.suggest("l'arbrz")).toEqual(["l'arbre", "l'arbres"]) }) it('does not split what has no apostrophe to split on', () => { expect(fr.correct("'arbre")).toBe(false) // nothing before the mark expect(fr.correct("l'")).toBe(false) // nothing after it }) it('hands back the same dictionary for a language that elides nothing', () => { // pt-PT and English pay nothing for this. expect(withElision(base, [])).toBe(base) expect(withElision(base, undefined)).toBe(base) }) })