Phase 19: the copy stops being hardcoded Mandarin

Every `中文 · English` string moves out of ~29 components into
web/src/i18n: one Pack type, a verbatim zh pack, and two ways to read
it — usePack() for components, pack() for the modules that build a line
when something happens rather than when something renders.

Anything with a value in it is a function on the pack rather than a
template at the call site, English pluralisation included: word order
isn't universal, and a pack author has to be able to move the number.
The roster constants (tones, rewrite styles, export formats, companions)
keep only value + emoji, so a label can't drift from its key.

On the server, internal/llm/lang.go replaces "Simplified Chinese" in the
three prompts that actually name her language. pt-PT is spelled
"European Portuguese (pt-PT, never Brazilian Portuguese)" in the prompt
itself, and each Lang carries her word for "why" so the tutor prompt
still recognises the question when she asks it her way.

pair_lang reaches the model through the row-scoped query each handler
already ran — the one that proves she owns the document — rather than a
second lookup that could disagree with it.

Also records Phase 18's deploy: migration 0011 rehearsed against a copy
of the live VPS database, then applied for real.
This commit is contained in:
prosolis
2026-07-27 08:37:05 -07:00
parent 30d5e691c9
commit 336cae93e0
45 changed files with 1331 additions and 371 deletions
+122
View File
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
import { zh } from './packs/zh'
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('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')
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)
})
// 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', () => {
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.
setPackLang('fr')
expect(seen).not.toHaveBeenCalled()
})
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('“its” = “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 天前')
})
// 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', () => {
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(zh, '')
expect(empties).toEqual([])
})
it('labels every companion in the roster and every tone the editor offers', async () => {
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()
}
const { TONES } = await import('../components/Editor/ToneSelect')
for (const tone of TONES) {
expect(zh.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()
}
})
})