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 { es } from './packs/es' 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, es] // Every string a pack would ever put on screen, and nothing else — field names // excluded (see the pt-PT grep below for what including them cost). Templates are // invoked so an interpolated line is checked as she'd read it, with the same // stand-in arguments the old JSON replacer used. function copyOf(p: Pack): string { const strings = (v: unknown): string[] => { if (typeof v === 'string') return [v] if (typeof v === 'function') return strings((v as (...a: unknown[]) => unknown)(1, 'x')) if (v && typeof v === 'object') return Object.values(v).flatMap(strings) return [] } return strings(p).join('\n') } 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, then 'es' until Phase 25 — every pair PairLang names now // has a pack, so the stand-in is a regional code Petal has not decided // about, which is the realistic version of this failure anyway.) setPackLang('es-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-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(['es', '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 }) // The chat-failure line is the only message the Ask Petal panel writes without // the model, and it renders through the same bilingual bubble as a real reply // (splitBilingual, blank line between the halves). A pack that writes it as // one language gets a bubble with a muted empty half — and, worse, tells the // half of the pair that can't read that language nothing at all. it.each(PACKS)('says the chat-failure line in both halves of the pair ($code)', async (p) => { const { splitBilingual } = await import('../components/Editor/bilingualReply') const { native, en } = splitBilingual(p.editor.chatFailed) expect(native, `${p.code} chatFailed has no pair-language half`).not.toBe('') expect(en, `${p.code} chatFailed has no English half`).not.toBe('') // The English half is the one every reader of every pack shares, so it is // the one worth pinning: a pack that translated it has lost the point. expect(en).toMatch(/ask me again/i) expect(native).not.toBe(en) }) // The status-bar count is the one line in Petal that grows a number, so it is // the one that can be quietly ungrammatical in three languages at once — and // the count itself has to survive translation, since it is the whole content. it.each(PACKS)('counts petals to polish in both halves, and agrees on the number ($code)', (p) => { for (const n of [1, 2, 5, 21]) { const { native, en } = p.status.petalsToPolish(n) expect(native, `${p.code} has no pair-language half for n=${n}`).toBeTruthy() expect(en, `${p.code} has no English half for n=${n}`).toBeTruthy() expect(native, `${p.code} drops the count from its native half`).toContain(String(n)) expect(en).toContain(String(n)) // English is the half every pack shares; a pack that translated it has lost // the point, exactly as with chatFailed above. expect(en).toMatch(/petals? to polish/) } // One is not many, in every language Petal ships. expect(p.status.petalsToPolish(1).native).not.toBe(p.status.petalsToPolish(2).native) expect(p.status.petalsToPolish(1).en).toContain('petal to polish') expect(p.status.petalsToPolish(2).en).toContain('petals to polish') }) // The triage legend is the only place a Petal binding is written down, so a // pack that drops a key drops the feature for that pair: nothing else on // screen says Tab moves to the next underline. The key caps themselves stay // as they are printed on the keyboard, which is why the English half is not // the interesting one — a pack may well translate "Entrée" and be right to. it.each(PACKS)('names every triage key in both halves ($code)', (p) => { const { native, en } = p.editor.triageHint expect(native, `${p.code} has no pair-language triage legend`).toBeTruthy() expect(en, `${p.code} has no English triage legend`).toBeTruthy() expect(en).toBe('Tab next · Enter accept · Del dismiss · ? Ask Petal · Esc exit') // Five bindings, five entries — in whatever the pack calls the keys. expect(native.split('·'), `${p.code} lists the wrong number of keys`).toHaveLength(5) expect(native, `${p.code} loses the Tab key`).toContain('Tab') expect(native, `${p.code} loses the Ask Petal key`).toContain('?') }) 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', () => { // The pack's *copy*, and only its copy. Keys are English identifiers and can // never be Brazilian, but they used to be in this haystack — and a naive // substring grep duly failed on `translateLabel`, which lowercases to // "transla·tela·bel" and so "contains" the pt-BR *tela*. A test that fires on // its own field names is a test that gets deleted the third time it does it. // // Lowercased: half this copy is sentences, and a form that only ever appears // at the start of one ("Actualmente…") would otherwise walk straight past // both greps below. const text = copyOf(ptPT).toLowerCase() // Canaries. Every assertion below is a *negative*, so a copyOf that quietly // returned nothing — or stopped invoking templates — would make this whole // test pass by having nothing to search. The second line is inside a // function, and only appears if templates are still being called. expect(text, 'copyOf collected no pack copy').toContain('sinónimos') expect(text, 'copyOf stopped invoking templates').toContain('já vais em 1 palavras') // 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)).toLowerCase() 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) }) }) describe('the es pack', () => { // Spanish has no regional question in its dictionary at all — hunspell-es // ships twenty country codes and every one is a symlink to one pan-Hispanic // word list — so, even more than with French, the entire regional decision // lives in this file. Neutral Latin American was chosen deliberately, and a // stray peninsular form is invisible to everyone reviewing the diff. it('is Latin American, not peninsular', () => { const text = copyOf(es).toLowerCase() for (const bad of [ 'ordenador', 'vosotros', 'zumo', 'patata', 'coche', 'gafas', 'billete', 'chaval', 'guay', // The one that is not merely regional: *coger* is an everyday verb in // Spain and obscene through most of Latin America. A companion in a // private notebook must never produce it by accident. 'coger', ]) { expect(text, `peninsular form "${bad}" in the es pack`).not.toMatch( new RegExp(`\\b${bad}\\b`), ) } // And the accent it is read aloud in. Six of Piper's nine Spanish voices // are es_ES, so the wrong country is the easy default here — the pt-PT // trap, not the fr non-question. expect(es.locale).toBe('es-MX') }) // Spanish opens its questions and exclamations, and the easiest place to // forget is exactly where a reviewer's eye slides past: inside a Line's // native half, and inside an interpolated template. Every opening mark in the // pack is deliberate; a missing one is a typo the type system cannot see. it('opens every question and exclamation it closes', () => { const lines: { native: string; where: string }[] = [] const walk = (node: unknown, path: string) => { if (typeof node === 'function') return walk((node as (...a: unknown[]) => unknown)(1, 'x'), path) if (!node || typeof node !== 'object') return const rec = node as Record // A Line is the one shape whose `native` is pure Spanish — the flat // "Spanish · English" strings carry English punctuation too, so they are // asserted by hand below rather than by rule. if (typeof rec.native === 'string' && typeof rec.en === 'string') { lines.push({ native: rec.native, where: path }) return } for (const [k, v] of Object.entries(rec)) walk(v, path ? `${path}.${k}` : k) } walk(es, '') expect(lines.length).toBeGreaterThan(40) // The one exemption, and it is not a question: `triageHint` is a legend of // key caps, and its "?" is the key she presses to ask Petal — the same // literal printed on the keyboard, no more Spanish punctuation than "Esc". const keyCaps = new Set(['editor.triageHint']) for (const { native, where } of lines) { if (keyCaps.has(where)) continue if (native.includes('?')) expect(native, `${where} closes ? without ¿`).toContain('¿') if (native.includes('!')) expect(native, `${where} closes ! without ¡`).toContain('¡') } // An exclamative opening with Qué/Cómo/Cuánto is the case that slips past a // reader, because it carries no closing "!" to look wrong against — the // whole pair is simply absent. The quorum review caught exactly one of // these ("Qué linda elección de palabra"), and only one reviewer of four // saw it, which is the argument for asserting it instead of re-reviewing it. for (const { native, where } of lines) { expect(native, `${where} opens an exclamative without ¡`).not.toMatch( /^(Qué|Cómo|Cuánto|Cuánta)\b/, ) } // And the two-language strings, by hand: both halves punctuate their own way. expect(es.garden.promptRecognition).toBe('¿Qué significa? · What does this mean?') expect(es.garden.promptProduction).toContain('¿Cuál es la palabra en inglés?') }) it('renders its interpolated lines with the value in place', () => { expect(es.app.duplicateTitle('Primavera')).toBe('Primavera (copia)') expect(es.companion.milestone(300).native).toContain('300 palabras') // Spanish agreement is the pack's business; the call site only ever passes // a number. `flor`/`flores` is the irregular one — it takes -es, not -s. expect(es.garden.reviewDue(1)).toContain('1 palabra ·') expect(es.garden.reviewDue(4)).toContain('4 palabras ·') expect(es.garden.growing(1)).toContain('1 flor en el jardín') expect(es.garden.growing(3)).toContain('3 flores en el jardín') expect(es.journal.kept(1)).toContain('1 cosa que te llevaste') expect(es.journal.kept(5)).toContain('5 cosas que te llevaste') expect(es.status.petalsToPolish(1).native).toContain('1 pétalo por pulir') expect(es.status.petalsToPolish(2).native).toContain('2 pétalos por pulir') }) it('says the collision line, which this pair meets constantly', () => { // real, red, once, pie, sin, pan, mayor, sale, ropa — Spanish and English // collide about as often as French and English do. expect(es.editor.alsoIn).toBeTruthy() expect(es.editor.alsoIn).not.toBe(zh.editor.alsoIn) expect(es.editor.alsoIn).not.toBe(fr.editor.alsoIn) expect(es.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('the es pair carries the ones that cost most', () => { // "embarrassed" is the reason this feature exists at all: *embarazada* is // "pregnant", and it is the single false friend most likely to be said out // loud to a room. "molest" is the other one that has to be here, because // *molestar* is an everyday word and the English is not. for (const word of ['embarrassed', 'molest', 'actually', 'realize', 'exit', 'carpet']) { expect(es.falseFriends[word], word).toBeDefined() } // Spanish shares more Latin with English than either of the other Latin // pairs, so this list is the longest of the four and should stay that way. expect(Object.keys(es.falseFriends).length).toBeGreaterThan( Object.keys(fr.falseFriends).length, ) }) 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) } } }) })