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:
prosolis
2026-07-27 12:43:02 -07:00
parent 4de83d0da5
commit ccb43e5a4d
22 changed files with 1458 additions and 107 deletions
+68 -15
View File
@@ -2,6 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
import { zh } from './packs/zh'
import { ptPT } from './packs/pt-PT'
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]
beforeEach(() => {
resetPackForTests()
@@ -15,10 +22,19 @@ describe('pack selection', () => {
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.
setPackLang('pt-PT')
setPackLang('fr')
expect(pack()).toBe(zh)
setPackLang('klingon')
expect(pack()).toBe(zh)
@@ -30,20 +46,19 @@ describe('pack selection', () => {
expect(pack()).toBe(zh)
})
// Only one pack ships today, so a *real* switch can't be exercised yet; what
// can be is the other half of that contract — that a no-op never wakes every
// reader in the app. The switching path gets its test when pt-PT lands.
it('never notifies readers when nothing actually changed', () => {
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()
// Standing in for a second pack until one ships: switching to something
// unshipped resolves back to zh, which is also not a change.
// An unshipped pair resolves back to zh, which is also not a change.
setPackLang('fr')
expect(seen).not.toHaveBeenCalled()
setPackLang('pt-PT')
expect(seen).toHaveBeenCalledTimes(1)
})
it('stops notifying after unsubscribe', () => {
@@ -87,7 +102,7 @@ describe('the zh pack', () => {
// 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('has no empty strings anywhere', () => {
it.each(PACKS)('has no empty strings anywhere ($code)', (p) => {
const empties: string[] = []
const walk = (node: unknown, path: string) => {
if (typeof node === 'string') {
@@ -99,28 +114,28 @@ describe('the zh pack', () => {
for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
}
}
walk(zh, '')
walk(p, '')
expect(empties).toEqual([])
})
it('labels every companion in the roster and every tone the editor offers', async () => {
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(zh.companion.names[c.id], `no name for companion ${c.id}`).toBeTruthy()
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(zh.tones[tone.value], `no label for tone ${tone.value}`).toBeTruthy()
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(zh.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
expect(p.styles[style.value], `no label for style ${style.value}`).toBeTruthy()
}
})
it('labels every word band the popover can show', async () => {
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')
@@ -136,7 +151,45 @@ describe('the zh pack', () => {
)
expect(bands.size).toBe(3)
for (const band of bands) {
expect(zh.editor.wordBands[band], `no label for word band ${band}`).toBeTruthy()
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 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')
})
it('says the collision line the zh pair never needed', () => {
// "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)
})
})