The es pair, and a dictionary that was quietly Spain's
Phase 25. Spanish was never built — the groundwork was all [x] (DreamDict data, the prompt language, the L1 rule gating, TTS env-discovery), which is why the plan read as though it had shipped. shippedPairs was the honest answer: the server had been refusing es on purpose. The langpack is neutral Latin American, chosen with the user: tú, ustedes, no vosotros, and the pan-American half of every vocabulary split. A vitest greps for the peninsular twins the way fr is greped for québécismes — including coger, which is not merely regional but obscene through most of Latin America. The dictionary is the story. Debian's hunspell-es symlinks twenty country codes to one file, which reads as pan-Hispanic; RLA publishes twenty-four builds per release, one per country plus a generic es that is the union, and Debian ships peninsular es_ES. The 58,622-form gap is essentially voseo, so the first version of this commit underlined vení and tenés as misspellings and called it a considered gap. The MUST_ACCEPT list was written to catch exactly that and structurally could not: it asserted the pan-Hispanic vocabulary, and every RLA variant carries the full pan-Hispanic vocabulary — only the paradigms are localised. The REP table cited as the second witness is shared by all builds too. Two independent-looking proofs, neither able to distinguish anything, agreeing with each other. The profile now demands what discriminates, each verified against the build it targets: voseo rejects es_ES and Debian, vosotros rejects es_MX, and arepa/chévere/bacán reject es_AR, which has both paradigms and would otherwise pass. 717,640 forms, 1.74 MB gzipped, 762 ms / 97 MB in a real nspell. fr and pt-PT rebuild byte-identical from their own upstream debs, so the shared script still means what it meant. Shipping the union is fr's call arrived at from the other side: coût and cout are both correct French, tienes and tenés are both correct Spanish. The dictionary holds every variety because underlining is all it can do; the copy picks a register because speaking requires one. Reviewed by four models at the usual >=2-of-4 threshold, 5 of 27 findings applied — one catching the bedtime proverb as fr's Qui dort dîne calqued into Spanish, gloss and all, which is the rule the fr header states. One below-threshold finding (a missing ¡, seen by 1 of 4 because an absent opening mark has no closing ! to look wrong against) was applied and turned into an assertion instead: the suite now rejects any native line that closes ? or ! without opening one. piper-es on es_MX-ald-medium, not the es_ES-davefx-medium the plan named — six of Piper's nine Spanish voices are peninsular, so the obvious pick was the pt-PT trap through a different door. go build/vet/test, tsc, vite, vitest 251/251. Not deployed, not seen in a browser, not read by a native speaker, and no es account exists.
This commit is contained in:
+114
-5
@@ -4,12 +4,13 @@ import { onPackChange, pack, resetPackForTests, setPackLang, shippedPacks } from
|
||||
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]
|
||||
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
|
||||
@@ -49,8 +50,10 @@ describe('pack selection', () => {
|
||||
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')
|
||||
// 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)
|
||||
@@ -70,7 +73,7 @@ describe('pack selection', () => {
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
|
||||
// An unshipped pair resolves back to zh, which is also not a change.
|
||||
setPackLang('es')
|
||||
setPackLang('es-ES')
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
|
||||
setPackLang('pt-PT')
|
||||
@@ -83,7 +86,7 @@ describe('pack selection', () => {
|
||||
// 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'])
|
||||
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)
|
||||
@@ -364,6 +367,97 @@ describe('the fr pack', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
for (const { native, where } of lines) {
|
||||
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.
|
||||
@@ -391,6 +485,21 @@ describe('false friends', () => {
|
||||
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)) {
|
||||
|
||||
Reference in New Issue
Block a user