The last of the UX review's item 8. Ctrl+. and Ctrl+, step through the underlines from anywhere in the text; the card that opens takes focus and answers Tab / Shift+Tab / Enter / Del / ? / Esc itself. Answering a card advances to the next by itself, and the last one closes and puts the caret back in the prose — so a document is triaged in five presses of Enter. The item asked for bare Tab or n/p. Neither can exist in a text editor: an unmodified letter is a letter. They work fine once a card holds focus, which is where the item wanted them; getting there needs a chord that is safe to press mid-sentence, and mid-composition, so the entry keys are IME-guarded like every other binding. The queue is the underlines read off the decoration DOM in document order, not the suggestion list: a stop she cannot see is worse than one she never visits, and it guarantees the card can anchor itself. Escape is stopped at the card. Unhandled it would also have left distraction-free mode, restoring the sidebar and — via the rail-follows-the- mode fix — pulling the rail out from under her mid-triage. The legend is bilingual and leads with the pair language, unlike the card's English buttons: those name what she is learning, this is an instruction for operating Petal, like the status bar. Key names are as printed on her keyboard (Entrée, Suppr, Intro, Supr). The es pack's own punctuation test caught the "?" and is right in general; the key cap is one named exemption. Verified in a real browser at 1517x810 on a fresh database with no model, over CDP — a keystroke feature deserves real keystrokes. Both layouts, wrap in both directions, the accept/dismiss/advance loop, Ask Petal and back, the full triage-to-empty criterion, and Accept-all clicked from a keyboard card. The wiring has no unit test for the reason items 6, 7 and 8 recorded: jsdom has no layout. triage.ts is pure and tested; browser-verified is written down as browser-verified. Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
546 lines
27 KiB
TypeScript
546 lines
27 KiB
TypeScript
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<string, unknown>
|
||
// 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)
|
||
}
|
||
}
|
||
})
|
||
})
|