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) } } }) })