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:
@@ -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('“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 天前')
|
||||
})
|
||||
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user