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.
143 lines
5.2 KiB
Go
143 lines
5.2 KiB
Go
package config
|
|
|
|
import "testing"
|
|
|
|
// The Piper instances are discovered from the environment rather than named in
|
|
// code, so that a new pair costs a compose service and two .env lines. These
|
|
// assert the discovery rule, including the two shapes that already exist in the
|
|
// wild (millenia's systemd unit and the VPS compose file).
|
|
func TestTTSVoicesDiscovery(t *testing.T) {
|
|
voices := ttsVoices([]string{
|
|
"TTS_ENDPOINT=http://piper-en:5000",
|
|
"TTS_VOICE_EN=en_US-amy-medium",
|
|
"TTS_ENDPOINT_ZH=http://piper-zh:5000/",
|
|
"TTS_VOICE_ZH=zh_CN-huayan-medium",
|
|
"TTS_ENDPOINT_PT=http://piper-pt:5000",
|
|
"TTS_VOICE_PT=pt_PT-tugão-medium",
|
|
"TTS_ENDPOINT_FR=http://piper-fr:5000",
|
|
"TTS_VOICE_FR=fr_FR-siwis-medium",
|
|
"TTS_ENDPOINT_ES=http://piper-es:5000",
|
|
"TTS_VOICE_ES=es_MX-ald-medium",
|
|
// Noise that must not become a language.
|
|
"TTS_PATH=/synthesize",
|
|
"PATH=/usr/bin",
|
|
})
|
|
|
|
want := map[string]TTSVoice{
|
|
"en": {"http://piper-en:5000", "en_US-amy-medium"},
|
|
// The trailing slash is trimmed here so the synthesis path concatenates
|
|
// cleanly rather than producing a double slash at every call site.
|
|
"zh": {"http://piper-zh:5000", "zh_CN-huayan-medium"},
|
|
"pt": {"http://piper-pt:5000", "pt_PT-tugão-medium"},
|
|
// Phase 24's whole TTS change: a fourth language costs two lines here
|
|
// and a compose service, and no Go at all.
|
|
"fr": {"http://piper-fr:5000", "fr_FR-siwis-medium"},
|
|
// And a fifth cost exactly the same, which is the claim actually being
|
|
// tested. The voice is Mexican on purpose: the es pack is Latin
|
|
// American, and es_ES-davefx-medium would read it in the wrong accent.
|
|
"es": {"http://piper-es:5000", "es_MX-ald-medium"},
|
|
}
|
|
if len(voices) != len(want) {
|
|
t.Fatalf("discovered %v, want %v", voices, want)
|
|
}
|
|
for lang, w := range want {
|
|
if voices[lang] != w {
|
|
t.Errorf("%s = %+v, want %+v", lang, voices[lang], w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Half a configuration is not a language. An endpoint with no voice (or the
|
|
// reverse) must read as "no voice for this language" — a 404 the client answers
|
|
// by falling back to Web Speech — rather than as an instance that exists and
|
|
// errors on every request.
|
|
func TestTTSVoicesIgnoresHalfConfiguredLanguages(t *testing.T) {
|
|
voices := ttsVoices([]string{
|
|
"TTS_ENDPOINT=http://piper-en:5000",
|
|
"TTS_VOICE_EN=en_US-amy-medium",
|
|
"TTS_ENDPOINT_FR=http://piper-fr:5000", // no TTS_VOICE_FR
|
|
"TTS_VOICE_ES=es_ES-davefx-medium", // no TTS_ENDPOINT_ES
|
|
})
|
|
if _, ok := voices["fr"]; ok {
|
|
t.Errorf("fr routed with no voice configured")
|
|
}
|
|
if _, ok := voices["es"]; ok {
|
|
t.Errorf("es routed with no endpoint configured")
|
|
}
|
|
if len(voices) != 1 {
|
|
t.Errorf("discovered %v, want English only", voices)
|
|
}
|
|
}
|
|
|
|
// A deployment that predates the map names only the endpoints and relies on the
|
|
// voice defaults; it must sound exactly as it did.
|
|
func TestTTSVoicesKeepsTheOriginalDefaults(t *testing.T) {
|
|
voices := ttsVoices([]string{
|
|
"TTS_ENDPOINT=http://127.0.0.1:5005",
|
|
"TTS_ENDPOINT_ZH=http://127.0.0.1:5006",
|
|
})
|
|
if got := voices["en"].Voice; got != "en_US-amy-medium" {
|
|
t.Errorf("en voice = %q, want the default", got)
|
|
}
|
|
if got := voices["zh"].Voice; got != "zh_CN-huayan-medium" {
|
|
t.Errorf("zh voice = %q, want the default", got)
|
|
}
|
|
}
|
|
|
|
// Read-aloud is off when no English instance is configured; nothing else may
|
|
// switch it on. (tts.New gates on TTSEndpoint, so a stray TTS_ENDPOINT_PT with
|
|
// no English sibling must not produce a routable map that outlives that gate.)
|
|
func TestTTSVoicesEmptyWithoutEndpoints(t *testing.T) {
|
|
if voices := ttsVoices([]string{"TTS_VOICE_EN=en_US-amy-medium"}); len(voices) != 0 {
|
|
t.Errorf("discovered %v, want none", voices)
|
|
}
|
|
}
|
|
|
|
// The fallback to the single local user is right on a laptop and a catastrophe
|
|
// on a public host, so it is defaulted from the origin Petal knows itself by
|
|
// rather than left to be remembered.
|
|
func TestRequireAuthDefaultsFromBaseURL(t *testing.T) {
|
|
cases := []struct {
|
|
baseURL string
|
|
want bool
|
|
}{
|
|
{"http://localhost:8080", false},
|
|
{"http://127.0.0.1:8080", false},
|
|
{"http://[::1]:8080", false},
|
|
{"", false}, // no BASE_URL set at all: the local-dev default
|
|
{"https://petal.parodia.dev", true},
|
|
{"http://petal.parodia.dev", true},
|
|
{"https://petal.example.com/", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Setenv("BASE_URL", c.baseURL)
|
|
t.Setenv("PETAL_REQUIRE_AUTH", "")
|
|
if got := Load().RequireAuth; got != c.want {
|
|
t.Errorf("BASE_URL=%q: RequireAuth=%v, want %v", c.baseURL, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The default is a default, not a rule: a trusted private network is a real
|
|
// deployment shape, and so is wanting the guard on locally.
|
|
func TestRequireAuthExplicitOverride(t *testing.T) {
|
|
t.Setenv("BASE_URL", "https://petal.parodia.dev")
|
|
t.Setenv("PETAL_REQUIRE_AUTH", "false")
|
|
if Load().RequireAuth {
|
|
t.Error("an explicit false must be honoured on a public origin")
|
|
}
|
|
|
|
t.Setenv("BASE_URL", "http://localhost:8080")
|
|
t.Setenv("PETAL_REQUIRE_AUTH", "true")
|
|
if !Load().RequireAuth {
|
|
t.Error("an explicit true must be honoured on localhost")
|
|
}
|
|
|
|
// A typo must not be the thing that disables the guard.
|
|
t.Setenv("BASE_URL", "https://petal.parodia.dev")
|
|
t.Setenv("PETAL_REQUIRE_AUTH", "nope")
|
|
if !Load().RequireAuth {
|
|
t.Error("an unparseable value must keep the default, not read as false")
|
|
}
|
|
}
|