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.
129 lines
4.3 KiB
Go
129 lines
4.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
)
|
|
|
|
// patchMe drives UpdateMeHandler as the given user would reach it: behind the
|
|
// middleware, which is the only thing that puts an id in the context.
|
|
func patchMe(t *testing.T, users *UserStore, id, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
r := httptest.NewRequest(http.MethodPatch, "/me", strings.NewReader(body))
|
|
r = r.WithContext(WithUser(r.Context(), id))
|
|
w := httptest.NewRecorder()
|
|
users.UpdateMeHandler()(w, r)
|
|
return w
|
|
}
|
|
|
|
func TestSetPairLang(t *testing.T) {
|
|
_, users, _ := newStores(t)
|
|
|
|
if err := users.SetPairLang("bob", "pt-PT"); err != nil {
|
|
t.Fatalf("set pt-PT: %v", err)
|
|
}
|
|
if u, _ := users.Get("bob"); u.PairLang != "pt-PT" {
|
|
t.Fatalf("pair_lang = %q, want pt-PT", u.PairLang)
|
|
}
|
|
|
|
// Every pair with a langpack, not just the first one: this list and the
|
|
// frontend's PACKS are two copies of the same fact, and the day they
|
|
// disagree is the day she can pick a pair the app cannot render.
|
|
if err := users.SetPairLang("bob", "fr"); err != nil {
|
|
t.Fatalf("set fr: %v", err)
|
|
}
|
|
if u, _ := users.Get("bob"); u.PairLang != "fr" {
|
|
t.Fatalf("pair_lang = %q, want fr", u.PairLang)
|
|
}
|
|
|
|
if err := users.SetPairLang("bob", "es"); err != nil {
|
|
t.Fatalf("set es: %v", err)
|
|
}
|
|
if u, _ := users.Get("bob"); u.PairLang != "es" {
|
|
t.Fatalf("pair_lang = %q, want es", u.PairLang)
|
|
}
|
|
|
|
// And back — a writer who tries a pair and doesn't like it must be able to
|
|
// return, which is the whole reason the picker exists.
|
|
if err := users.SetPairLang("bob", "zh"); err != nil {
|
|
t.Fatalf("set zh: %v", err)
|
|
}
|
|
if u, _ := users.Get("bob"); u.PairLang != "zh" {
|
|
t.Fatalf("pair_lang = %q, want zh", u.PairLang)
|
|
}
|
|
}
|
|
|
|
// A pair the frontend has no langpack for must not be storable. Accepting it
|
|
// would leave her looking at Chinese copy with no way back except a lucky guess.
|
|
func TestSetPairLangRejectsUnshippedPairs(t *testing.T) {
|
|
_, users, _ := newStores(t)
|
|
|
|
// The near-misses are the ones that matter, and there are two of them now.
|
|
// "pt-BR" must not be quietly served European copy and a European voice;
|
|
// "es-ES" is the same mistake pointing the other way, because the es pack is
|
|
// deliberately Latin American and reads itself aloud in a Mexican voice. A
|
|
// regional code Petal has not decided about is refused rather than rounded
|
|
// to the nearest pack it happens to have.
|
|
for _, lang := range []string{"es-ES", "pt-BR", "fr-CA", "de", "klingon", "", " "} {
|
|
if err := users.SetPairLang("bob", lang); err == nil {
|
|
t.Fatalf("stored unshipped pair %q", lang)
|
|
}
|
|
}
|
|
if u, _ := users.Get("bob"); u.PairLang != "zh" {
|
|
t.Fatalf("a refused write still moved pair_lang to %q", u.PairLang)
|
|
}
|
|
}
|
|
|
|
func TestSetPairLangUnknownUser(t *testing.T) {
|
|
_, users, _ := newStores(t)
|
|
if err := users.SetPairLang("nobody", "pt-PT"); err == nil {
|
|
t.Fatal("set a pair language on an account that does not exist")
|
|
}
|
|
}
|
|
|
|
func TestUpdateMeHandler(t *testing.T) {
|
|
_, users, _ := newStores(t)
|
|
|
|
w := patchMe(t, users, "bob", `{"pair_lang":"pt-PT"}`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
// The whole user comes back, so the client can re-read the pair from the
|
|
// server instead of assuming its request took.
|
|
var got db.User
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.ID != "bob" || got.PairLang != "pt-PT" {
|
|
t.Fatalf("response = %+v, want bob on pt-PT", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateMeHandlerRejects(t *testing.T) {
|
|
_, users, _ := newStores(t)
|
|
|
|
for name, body := range map[string]string{
|
|
"unshipped pair": `{"pair_lang":"es-ES"}`,
|
|
"missing field": `{}`,
|
|
"not json": `pt-PT`,
|
|
} {
|
|
if w := patchMe(t, users, "bob", body); w.Code != http.StatusBadRequest {
|
|
t.Fatalf("%s: status = %d, want 400", name, w.Code)
|
|
}
|
|
}
|
|
if u, _ := users.Get("bob"); u.PairLang != "zh" {
|
|
t.Fatalf("a rejected request still moved pair_lang to %q", u.PairLang)
|
|
}
|
|
|
|
// A caller the middleware never resolved (or whose row is gone) is a lapsed
|
|
// session, not a bad request — the client turns 401 into the sign-in overlay.
|
|
if w := patchMe(t, users, "nobody", `{"pair_lang":"pt-PT"}`); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unknown user: status = %d, want 401", w.Code)
|
|
}
|
|
}
|