Phase 24, the fr half: langpack, Hunspell dictionary, Piper voice, and the lexicon coverage that turned out to have been measured already (63.1%, better than pt-PT's 62.1%). No migration; not deployed. The plan recorded that build_ptpt_dictionary.py "generalizes" to French. It did not. It handled single-character flags and plain PFX/SFX and stopped on everything else, and fr.aff uses four of the things it stopped on. FLAG long is the dangerous one: French flags are two characters, so the old reader's set(flagstr) yields a bag of unrelated letters and expands every entry through the wrong paradigm without ever erroring. Plus continuation flags (French really does affix an affixed form), NEEDAFFIX on 68,075 of 84,140 stems, and FULLSTRIP. Renamed build_hunspell_dictionary.py with a per-language profile, asserting that CIRCUMFIX and FORBIDDENWORD are still unused rather than assuming it — and it rebuilds pt-PT byte-identical to the shipped asset, which is the only thing that makes "generalized" a claim rather than a hope. Elision was decided by building both halves and measuring. Keeping l'arbre and its thirty-three siblings: 3,159,832 forms, 8.25 MB gzipped. Dropping them: 473,326 and 1.19 MB. They are not new words, but the tokenizer keeps internal apostrophes, so they genuinely would have been underlined — so they moved out of the dictionary into withElision, which splits at a known clitic and still requires the remainder to be a word (l'zzzz stays flagged). Real nspell: 369 ms and 74 MB, against pt-PT's 842 ms and 139 MB, on the larger language. Where the regional trap lives is the mirror image of Portuguese's: every fr_* Piper voice is fr_FR and Debian's fr_FR/fr_CA/fr_BE dictionaries are one shared word list, so nothing can be quietly wrong about the country and the whole decision sits in the copy. What French has instead is the 1990 reform, packaged three ways; comprehensive ships, because Petal never corrects her French and coût and cout are both correct. Then the interim review pass, at the user's suggestion and explicitly "for now": four models read each Latin pack independently, and only findings at least two of them reached on their own were applied — five per pack. It earned its keep on the pack that was already live. pt-PT was carrying pre-Acordo spellings (adjectivos, actualmente) in a file whose own header commits to post-Acordo, plus Brazilian decepção, because the Phase 21 greps checked for Brazilian vocabulary and never checked the pack against its own spelling policy. That grep now exists and was confirmed to fail on the old text before being kept. Where reviewers agreed a line was wrong but split on the fix, the wording is mine and the reasoning is in BUILD_PLAN rather than averaged away. Still owed, and both packs now say so precisely: a quorum of models agreeing is agreement, not authority. No native speaker has read either pack, and none of this has been seen in a browser. go build/vet/test clean, tsc, vite build, vitest 190/190. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
350 lines
16 KiB
TypeScript
350 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
import { onPackChange, pack, resetPackForTests, setPackLang, shippedPacks } from './index'
|
||
import { zh } from './packs/zh'
|
||
import { ptPT } from './packs/pt-PT'
|
||
import { fr } from './packs/fr'
|
||
import type { Pack } from './types'
|
||
|
||
// Every pack that ships. Shape assertions run over all of them, because the
|
||
// point of Phase 19 was that a language is data — and data that only the first
|
||
// author's pack satisfies isn't a shape, it's a coincidence.
|
||
const PACKS: Pack[] = [zh, ptPT, fr]
|
||
|
||
beforeEach(() => {
|
||
resetPackForTests()
|
||
})
|
||
|
||
describe('pack selection', () => {
|
||
it('speaks the default pair before /api/me answers', () => {
|
||
// Modules that build copy at import time (the companion, the prose checker)
|
||
// read the pack before the session is known. That read must not be blank.
|
||
expect(pack()).toBe(zh)
|
||
expect(pack().code).toBe('zh')
|
||
})
|
||
|
||
it('switches to a shipped pack when the session names one', () => {
|
||
setPackLang('pt-PT')
|
||
expect(pack()).toBe(ptPT)
|
||
expect(pack().code).toBe('pt-PT')
|
||
// And back — a writer moving pairs must not strand the app on the old copy.
|
||
setPackLang('zh')
|
||
expect(pack()).toBe(zh)
|
||
})
|
||
|
||
it('falls back rather than blanking on a pair with no pack yet', () => {
|
||
// A pair_lang the deployment has no copy for is a deployment that got ahead
|
||
// of its translation. She should still get a working editor. (This was 'fr'
|
||
// until Phase 24 gave fr a pack; 'es' is the pair still waiting for one.)
|
||
setPackLang('es')
|
||
expect(pack()).toBe(zh)
|
||
setPackLang('klingon')
|
||
expect(pack()).toBe(zh)
|
||
setPackLang('')
|
||
expect(pack()).toBe(zh)
|
||
setPackLang(null)
|
||
expect(pack()).toBe(zh)
|
||
setPackLang(undefined)
|
||
expect(pack()).toBe(zh)
|
||
})
|
||
|
||
it('notifies readers on a real switch, and only on a real one', () => {
|
||
const seen = vi.fn()
|
||
onPackChange(seen)
|
||
|
||
setPackLang('zh') // already the current pack — nothing changed
|
||
expect(seen).not.toHaveBeenCalled()
|
||
|
||
// An unshipped pair resolves back to zh, which is also not a change.
|
||
setPackLang('es')
|
||
expect(seen).not.toHaveBeenCalled()
|
||
|
||
setPackLang('pt-PT')
|
||
expect(seen).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
// What the sidebar picker offers. It is derived from the packs rather than
|
||
// listed a second time, so a pack that ships is a pair she can choose — and a
|
||
// pair with no pack can never be offered, which is the invariant the server's
|
||
// matching allowlist exists to enforce from the other side.
|
||
it('offers exactly the pairs it has copy for', () => {
|
||
const codes = shippedPacks().map((p) => p.code)
|
||
expect(codes.sort()).toEqual(['fr', 'pt-PT', 'zh'])
|
||
// Every offered pair names itself, because a writer stranded on the wrong
|
||
// pack can only read the label that is in her own language.
|
||
for (const p of shippedPacks()) expect(p.nativeName.length).toBeGreaterThan(0)
|
||
// Anything the picker offers must actually resolve.
|
||
for (const code of codes) {
|
||
setPackLang(code)
|
||
expect(pack().code).toBe(code)
|
||
}
|
||
})
|
||
|
||
it('stops notifying after unsubscribe', () => {
|
||
const seen = vi.fn()
|
||
const off = onPackChange(seen)
|
||
off()
|
||
setPackLang('zh')
|
||
expect(seen).not.toHaveBeenCalled()
|
||
})
|
||
})
|
||
|
||
describe('the zh pack', () => {
|
||
// Sentinels: a handful of strings copied from the pre-Phase-19 source. The
|
||
// point of the extraction was that nothing she reads changed, and a reworded
|
||
// label would otherwise be invisible in a diff of this size.
|
||
it('carries the original copy verbatim', () => {
|
||
expect(zh.docs.searchPlaceholder).toBe('搜索 · Search')
|
||
expect(zh.editor.addToDictionary).toBe('添加到词典 · Add to dictionary')
|
||
expect(zh.status.savedLocally).toBe('已保存在本机 · Kept on this device')
|
||
expect(zh.garden.titleWithFlower).toBe('🌷 词汇花园 · Vocabulary Garden')
|
||
expect(zh.companion.greeting).toEqual({
|
||
native: '嗨~我在这儿陪你写作哦 🐱',
|
||
en: "Hi! I'm right here keeping you company. 🐱",
|
||
})
|
||
expect(zh.prose.itsOwn).toBe('“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。')
|
||
})
|
||
|
||
it('renders its interpolated lines with the value in place', () => {
|
||
expect(zh.app.duplicateTitle('Spring')).toBe('Spring (副本)')
|
||
expect(zh.companion.milestone(300).native).toBe('哇!已经 300 个词了,太厉害了 🎉')
|
||
expect(zh.prose.articleAn('apple')).toContain('“an apple”')
|
||
expect(zh.prose.uncountable('informations', 'information')).toContain('“information”')
|
||
expect(zh.garden.reviewDue(4)).toBe('复习 4 个词 · Review 4 due 🌸')
|
||
// English pluralisation is the pack's job, not the call site's.
|
||
expect(zh.garden.growing(1)).toContain('1 blossom growing')
|
||
expect(zh.garden.growing(2)).toContain('2 blossoms growing')
|
||
expect(zh.history.daysAgo(1)).toBe('1 day ago · 1 天前')
|
||
expect(zh.history.daysAgo(3)).toBe('3 days ago · 3 天前')
|
||
expect(zh.journal.kept(1)).toContain('1 thing you took on board')
|
||
expect(zh.journal.kept(9)).toContain('9 things you took on board')
|
||
expect(zh.journal.stuck('make a decision', 3)).toContain('3 of your pieces')
|
||
})
|
||
|
||
// A pack with a hole in it renders an empty label rather than failing, which
|
||
// is exactly the kind of thing that reaches production. Types catch a missing
|
||
// *key*; only this catches an empty *value*.
|
||
it.each(PACKS)('has no empty strings anywhere ($code)', (p) => {
|
||
const empties: string[] = []
|
||
const walk = (node: unknown, path: string) => {
|
||
if (typeof node === 'string') {
|
||
if (node.trim() === '') empties.push(path)
|
||
return
|
||
}
|
||
if (typeof node === 'function') return // exercised above
|
||
if (node && typeof node === 'object') {
|
||
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
|
||
}
|
||
}
|
||
walk(p, '')
|
||
expect(empties).toEqual([])
|
||
})
|
||
|
||
// The voice read-aloud speaks this pair in. A pack that names a locale no
|
||
// Piper voice exists for degrades to Web Speech, which is survivable; a pack
|
||
// that names the *wrong region* does not announce itself at all — it just
|
||
// reads her language back to her in the accent the pair exists to avoid.
|
||
it.each(PACKS)('names a speakable locale for its own language ($code)', (p) => {
|
||
expect(p.locale, `${p.code} has no locale`).toMatch(/^[a-z]{2}(-[A-Za-z]{2,4})?$/)
|
||
expect(p.locale.split('-')[0]).toBe(p.code.split('-')[0])
|
||
if (p.code === 'pt-PT') expect(p.locale).toBe('pt-PT') // never pt-BR
|
||
})
|
||
|
||
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
|
||
const { COMPANIONS } = await import('../components/Companion/companions')
|
||
for (const c of COMPANIONS) {
|
||
expect(p.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
|
||
}
|
||
|
||
const { TONES } = await import('../components/Editor/ToneSelect')
|
||
for (const tone of TONES) {
|
||
expect(p.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
|
||
}
|
||
|
||
const { REWRITE_STYLES } = await import('../components/Editor/SelectionBubble')
|
||
for (const style of REWRITE_STYLES) {
|
||
expect(p.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
|
||
}
|
||
})
|
||
|
||
it.each(PACKS)('labels every word band the popover can show ($code)', async (p) => {
|
||
// wordBand returns a band name, never a label — an unlabelled band would
|
||
// render as an empty chip, which reads as a bug rather than as no data.
|
||
const { wordBand } = await import('../components/Editor/wordband')
|
||
const bands = new Set(
|
||
[
|
||
wordBand(0, 0.1),
|
||
wordBand(0, 0.5),
|
||
wordBand(0, 0.9),
|
||
wordBand(1000, -1),
|
||
wordBand(500, -1),
|
||
wordBand(10, -1),
|
||
].filter((b) => b !== null),
|
||
)
|
||
expect(bands.size).toBe(3)
|
||
for (const band of bands) {
|
||
expect(p.editor.wordBands[band], `no label for word band ${band}`).toBeTruthy()
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('the pt-PT pack', () => {
|
||
// The pack is European Portuguese or it is nothing: a Brazilian form in the
|
||
// chrome is exactly the drift SUGGESTIONS.md §3 says to guard against, and it
|
||
// is invisible to anyone who doesn't read Portuguese — including whoever
|
||
// reviews this diff.
|
||
it('is European Portuguese, not Brazilian', () => {
|
||
const text = JSON.stringify(ptPT, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v))
|
||
|
||
// Brazilian spellings and vocabulary that would give the pack away.
|
||
for (const bad of ['sinônimo', 'acadêmico', 'arquivo', 'tela', 'salvar', 'deletar', 'usuário', 'você']) {
|
||
expect(text, `pt-BR form "${bad}" in the pt-PT pack`).not.toContain(bad)
|
||
}
|
||
|
||
// And pre-Acordo spellings, which this grep did NOT cover until Phase 24's
|
||
// review pass found *adjectivos*, *actualmente* and *decepção* sitting in a
|
||
// file whose own header commits to post-Acordo. Guarding the pt-BR fault
|
||
// line while leaving the pack's stated spelling policy unchecked is half a
|
||
// test: both are invisible to a reviewer who doesn't read Portuguese.
|
||
// Whole words only — the English "actually" and "factual" contain "actual".
|
||
for (const bad of ['actualmente', 'adjectivo', 'decepção', 'acção', 'óptimo',
|
||
'recepção', 'objectivo', 'directamente', 'exacto']) {
|
||
expect(text, `pre-Acordo form "${bad}" in the pt-PT pack`).not.toContain(bad)
|
||
}
|
||
|
||
// And the European forms that should be there instead.
|
||
expect(ptPT.editor.synonyms).toContain('Sinónimos')
|
||
expect(ptPT.styles.academic.native).toBe('Académico')
|
||
expect(ptPT.auth.signIn).toContain('Iniciar sessão')
|
||
})
|
||
|
||
it('renders its interpolated lines with the value in place', () => {
|
||
expect(ptPT.app.duplicateTitle('Primavera')).toBe('Primavera (cópia)')
|
||
expect(ptPT.companion.milestone(300).native).toContain('300 palavras')
|
||
// Portuguese agreement is the pack's business, the same way English
|
||
// pluralisation is — the call site only ever passes a number.
|
||
expect(ptPT.garden.reviewDue(1)).toContain('1 palavra ·')
|
||
expect(ptPT.garden.reviewDue(4)).toContain('4 palavras ·')
|
||
expect(ptPT.garden.growing(1)).toContain('1 flor no jardim')
|
||
expect(ptPT.garden.growing(3)).toContain('3 flores no jardim')
|
||
expect(ptPT.journal.kept(1)).toContain('1 coisa que')
|
||
expect(ptPT.journal.kept(5)).toContain('5 coisas que')
|
||
})
|
||
|
||
// The growth journal is the one surface that talks about her progress, so it
|
||
// is the one most easily spoiled by a stray comparison. The rule is enforced
|
||
// in SQL on the backend; here it is enforced in the copy.
|
||
it('keeps the journal to growth and to her own past self', () => {
|
||
const text = JSON.stringify(
|
||
PACKS.map((p) => p.journal),
|
||
(_k, v) => (typeof v === 'function' ? JSON.stringify(v(2, 3)) : v),
|
||
)
|
||
for (const bad of ['error', 'mistake', 'wrong', 'streak', 'average', 'erro', 'errada',
|
||
'erreur', 'faute', 'moyenne', '错误']) {
|
||
expect(text.toLowerCase(), `the journal must not talk about "${bad}"`).not.toContain(bad)
|
||
}
|
||
})
|
||
|
||
it('says the collision line the zh pair never needed (pt-PT)', () => {
|
||
// "sale", "comum" and "tarde" are words on both sides of this pair, so the
|
||
// word card's second reading is reachable copy here — unlike in zh.
|
||
expect(ptPT.editor.alsoIn).toBeTruthy()
|
||
expect(ptPT.editor.alsoIn).not.toBe(zh.editor.alsoIn)
|
||
})
|
||
})
|
||
|
||
describe('the fr pack', () => {
|
||
// French's regional question is not the dictionary's — Debian's fr_FR, fr_CA
|
||
// and fr_BE are all symlinks to one word list — so the whole of it lives in
|
||
// this file, which makes it exactly as invisible to a reviewer as the pt-BR
|
||
// forms were, and worth pinning the same way.
|
||
it('is metropolitan French, not Québécois', () => {
|
||
const text = JSON.stringify(fr, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v))
|
||
for (const bad of ['courriel', 'clavarder', 'magasiner', 'fin de semaine', 'baladodiffusion']) {
|
||
expect(text, `Québécois form "${bad}" in the fr pack`).not.toContain(bad)
|
||
}
|
||
// And the voice it is read in, for the same reason the locale is pinned.
|
||
expect(fr.locale).toBe('fr-FR')
|
||
})
|
||
|
||
// The pack punctuates the way French does, which is the same habit
|
||
// prose.spaceBeforePunct tells her not to carry into her English. Both halves
|
||
// of that are deliberate and either would look like a typo to a tidier.
|
||
it('keeps French spacing and guillemets in its own copy', () => {
|
||
expect(fr.companion.greeting.native).toContain(' !')
|
||
expect(fr.prose.articleAn('apple')).toContain('« an apple »')
|
||
expect(fr.prose.spaceBeforePunct).toContain('inverse du français')
|
||
})
|
||
|
||
it('renders its interpolated lines with the value in place', () => {
|
||
expect(fr.app.duplicateTitle('Printemps')).toBe('Printemps (copie)')
|
||
expect(fr.companion.milestone(300).native).toContain('300 mots')
|
||
// French agreement is the pack's business, the same way English
|
||
// pluralisation is — the call site only ever passes a number.
|
||
expect(fr.garden.reviewDue(1)).toContain('1 mot ·')
|
||
expect(fr.garden.reviewDue(4)).toContain('4 mots ·')
|
||
expect(fr.garden.growing(1)).toContain('1 fleur au jardin')
|
||
expect(fr.garden.growing(3)).toContain('3 fleurs au jardin')
|
||
expect(fr.journal.kept(1)).toContain('1 chose que tu as retenue')
|
||
expect(fr.journal.kept(5)).toContain('5 choses que tu as retenues')
|
||
})
|
||
|
||
it('says the collision line, which this pair needs most of all', () => {
|
||
// English took so much from French that the collisions are the rule rather
|
||
// than the exception: chat, pain, coin, sale, four, car, or.
|
||
expect(fr.editor.alsoIn).toBeTruthy()
|
||
expect(fr.editor.alsoIn).not.toBe(zh.editor.alsoIn)
|
||
expect(fr.editor.alsoIn).not.toBe(ptPT.editor.alsoIn)
|
||
})
|
||
})
|
||
|
||
// False friends are a per-pair dataset rather than copy: the Latin pairs carry
|
||
// the traps their writers actually fall into, and the zh pair legitimately has
|
||
// none. Both halves of that are worth pinning.
|
||
describe('false friends', () => {
|
||
it('the zh pair has none, because the trap needs a shared script', () => {
|
||
expect(Object.keys(zh.falseFriends)).toHaveLength(0)
|
||
})
|
||
|
||
it('the pt-PT pair carries the ones that cost most', () => {
|
||
// Not an exhaustive list — these are the four every European Portuguese
|
||
// speaker meets in their first month of writing English.
|
||
for (const word of ['actually', 'pretend', 'realize', 'library']) {
|
||
expect(ptPT.falseFriends[word], word).toBeDefined()
|
||
}
|
||
expect(Object.keys(ptPT.falseFriends).length).toBeGreaterThan(10)
|
||
})
|
||
|
||
it('the fr pair carries the ones that cost most', () => {
|
||
// "attend" and "pass" are the two this pair has and pt-PT does not: French
|
||
// *attendre* is to wait, and *passer un examen* is to sit one, not to pass
|
||
// it — which is the false friend most likely to end up in a real letter.
|
||
for (const word of ['actually', 'library', 'attend', 'pass', 'sensible']) {
|
||
expect(fr.falseFriends[word], word).toBeDefined()
|
||
}
|
||
expect(Object.keys(fr.falseFriends).length).toBeGreaterThan(10)
|
||
})
|
||
|
||
it('is keyed by the lowercase English word, so a lookup can find it', () => {
|
||
for (const p of PACKS) {
|
||
for (const key of Object.keys(p.falseFriends)) {
|
||
expect(key, `${p.code}: "${key}" must be lowercase`).toBe(key.toLowerCase())
|
||
expect(p.falseFriends[key].native.trim(), key).not.toBe('')
|
||
expect(p.falseFriends[key].en.trim(), key).not.toBe('')
|
||
}
|
||
}
|
||
})
|
||
|
||
// The heads-up must never read as an accusation: she may well have meant the
|
||
// word. It says what the English one means and stops there.
|
||
it('never tells her she is wrong', () => {
|
||
const forbidden = /wrong|mistake|error|incorrect|don't use|do not use|errado|erro|incorrecto/i
|
||
for (const p of PACKS) {
|
||
for (const [key, line] of Object.entries(p.falseFriends)) {
|
||
expect(line.native, `${p.code}/${key}`).not.toMatch(forbidden)
|
||
expect(line.en, `${p.code}/${key}`).not.toMatch(forbidden)
|
||
}
|
||
}
|
||
})
|
||
})
|