Files
petal/internal/auth/pairlang_test.go
T
prosolis 77f284f65c The zh pair's other direction, and a rule pack that mostly says no
`pair_lang` had always been answering a second question nobody asked: it
says which two languages, and every surface built on it assumed English
was the one being learned. That is why hanzi is never tokenized, never
spell-checked, never glossed — correct for a Mandarin native practising
English, backwards for an English native practising Mandarin.
`users.direction` (migration 0016) separates the two questions; a
`zh-learner` pair code would have been cheaper and would have made two
directions of one pair look like two unrelated languages to every query.

Segmentation is what replaces `wordAt` where there are no spaces: a
shortest-path walk over log-probabilities, 232 ms and 14 MB for 188,522
words. The browser gets the word list because segmentation runs on hover;
the server keeps the whole dictionary. Their coverage gates come out
opposite on purpose — the client list is frequency-gated because the
segmentation is measurably identical without the tail, and the dictionary
is gated by nothing, because its only power is to explain and the word a
learner stops on is the rare one.

The 错别字 pack is 24 confusable pairs behind two mechanical gates. One
admits a pair only if the wrong form is not a dictionary word and the
right form is, which is why it refuses 自已 for 自己 — a real error whose
wrong form is a headword. The other asks the segmenter whether the two
characters already belong to two different words, without which 自己经常,
睡觉的时候 and 不知到底 would all be corrupted silently into text still
made of real characters.

Not deployed (this carries a migration), not seen in a browser, and no
account has ever been in the learner direction. The IME composition
guards were in scope and are not done — see BUILD_PLAN Phase 26.
2026-07-28 19:04:53 -07:00

242 lines
9.1 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.SetPair("bob", "pt-PT", DirectionLearningEn); 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.SetPair("bob", "fr", DirectionLearningEn); 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.SetPair("bob", "es", DirectionLearningEn); 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.SetPair("bob", "zh", DirectionLearningEn); 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.SetPair("bob", lang, DirectionLearningEn); 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.SetPair("nobody", "pt-PT", DirectionLearningEn); 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"}`,
"unknown direction": `{"direction":"learning_klingon"}`,
"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)
}
}
// An empty body used to be a 400, back when pair_lang was the only field and a
// request that named none of it could only be a client bug. With two optional
// fields it is an ordinary PATCH that changes nothing, and it has to be: the
// picker sends one field without knowing the other, and "omitted" has to mean
// "leave it alone" for that to be safe.
func TestUpdateMeHandlerEmptyBodyChangesNothing(t *testing.T) {
_, users, _ := newStores(t)
if err := users.SetPair("bob", "zh", DirectionLearningPair); err != nil {
t.Fatalf("set up: %v", err)
}
w := patchMe(t, users, "bob", `{}`)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body.String())
}
u, _ := users.Get("bob")
if u.PairLang != "zh" || u.Direction != DirectionLearningPair {
t.Fatalf("empty PATCH moved the account to %q/%q", u.PairLang, u.Direction)
}
}
// The direction axis: an account can be turned around and turned back, and the
// default every existing row already carries is the one it had before the column
// existed.
func TestDirectionRoundTrip(t *testing.T) {
_, users, _ := newStores(t)
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
t.Fatalf("a fresh account starts at %q, want %q", u.Direction, DirectionLearningEn)
}
w := patchMe(t, users, "bob", `{"direction":"learning_pair"}`)
if w.Code != http.StatusOK {
t.Fatalf("turn around: status = %d (%s)", w.Code, w.Body.String())
}
var got db.User
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
// The response carries the direction, not just the pair — the client reads
// its whole state back from here rather than assuming the write took.
if got.Direction != DirectionLearningPair || got.PairLang != "zh" {
t.Fatalf("response = %+v, want bob learning zh", got)
}
if w := patchMe(t, users, "bob", `{"direction":"learning_en"}`); w.Code != http.StatusOK {
t.Fatalf("turn back: status = %d (%s)", w.Code, w.Body.String())
}
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
t.Fatalf("direction = %q after turning back", u.Direction)
}
}
// The refusal this axis exists to make: a pair with no word list cannot be
// learned toward, however good its langpack is. fr, es and pt-PT all have copy,
// voices and spelling dictionaries — and nothing that could segment a sentence
// or read from that language into English, which is what a learner needs.
func TestLearnerDirectionRefusedForPairsWithoutData(t *testing.T) {
_, users, _ := newStores(t)
for _, lang := range []string{"pt-PT", "fr", "es"} {
if err := users.SetPair("bob", lang, DirectionLearningEn); err != nil {
t.Fatalf("set %s: %v", lang, err)
}
w := patchMe(t, users, "bob", `{"direction":"learning_pair"}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: status = %d, want 400", lang, w.Code)
}
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
t.Fatalf("%s: a refused write still moved direction to %q", lang, u.Direction)
}
}
}
// The two-field combination the handler validates as one decision. An account
// already learning Chinese that asks only to change pair is asking for a state
// neither field names on its own — French with segmentation — and it must not
// arrive by leaving one field out.
func TestPairChangeCannotStrandTheLearnerDirection(t *testing.T) {
_, users, _ := newStores(t)
if err := users.SetPair("bob", "zh", DirectionLearningPair); err != nil {
t.Fatalf("set up: %v", err)
}
if w := patchMe(t, users, "bob", `{"pair_lang":"fr"}`); w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
u, _ := users.Get("bob")
if u.PairLang != "zh" || u.Direction != DirectionLearningPair {
t.Fatalf("refused write left the account at %q/%q", u.PairLang, u.Direction)
}
// Naming both at once is how that move is actually made, and it works.
if w := patchMe(t, users, "bob", `{"pair_lang":"fr","direction":"learning_en"}`); w.Code != http.StatusOK {
t.Fatalf("both fields: status = %d (%s)", w.Code, w.Body.String())
}
if u, _ := users.Get("bob"); u.PairLang != "fr" || u.Direction != DirectionLearningEn {
t.Fatalf("account = %q/%q, want fr/learning_en", u.PairLang, u.Direction)
}
}
// The CHECK constraint is the last line, below the handler and below SetPair:
// a direction that reaches the column by any other route is still refused.
func TestDirectionCheckConstraint(t *testing.T) {
_, users, database := newStores(t)
if _, err := database.Exec(`UPDATE users SET direction = 'sideways' WHERE id = 'bob'`); err == nil {
t.Fatal("the users.direction CHECK accepted 'sideways'")
}
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
t.Fatalf("direction = %q after a refused UPDATE", u.Direction)
}
}