The garden learns which language a card is in, and read-aloud stops guessing

Phase 28 (c), the last of the phase. A word met inside a Portuguese
document is a Portuguese card: migration 0018 mirrors documents.doc_lang
onto vocab_words, set server-side from the ownership lookup capture was
already making. Every card still reviews — filtering the queue to the
half she is learning would drop the words she actually met.

Read-aloud was the larger surprise. detectLang routed Han/kana to
Chinese and everything else to en-US, so the zh pair was accidentally
right and every Latin pair wrong. doc_lang now reaches the client
read-only on the document JSON, and docLang(text, verdict) answers for a
passage taken out of it — with the script test still winning, because
quoted Chinese must never be spelled out one "Chinese letter" at a time.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
This commit is contained in:
prosolis
2026-07-28 23:45:26 -07:00
parent 29eb2fe1fc
commit 466055020f
15 changed files with 352 additions and 57 deletions
+86
View File
@@ -253,3 +253,89 @@ func TestDocLinkSurvivesDocDelete(t *testing.T) {
t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
}
}
// TestCaptureTakesLanguageFromItsDocument is the garden's half of Phase 28: a
// word met inside a document written in her own language is a card in that
// language, and the server reads that off the document rather than being told.
//
// The three cases are the three the client can actually produce: a lookup inside
// a flipped document, a lookup inside an English one, and a lookup with no
// document at all (the search box) — the last of which is '', which reads as
// English everywhere this value is used.
func TestCaptureTakesLanguageFromItsDocument(t *testing.T) {
srv, database := newTestServer(t)
seed := func(lang string) string {
t.Helper()
var id string
if err := database.QueryRow(
`INSERT INTO documents (user_id, content_text, doc_lang) VALUES (?, 'hi', ?) RETURNING id`,
db.LocalUserID, lang,
).Scan(&id); err != nil {
t.Fatalf("seed doc: %v", err)
}
return id
}
pairDoc, enDoc := seed("pair"), seed("en")
capture := func(word, body string) Word {
t.Helper()
rec := do(t, srv, http.MethodPost, "/vocab", body)
if rec.Code != http.StatusCreated {
t.Fatalf("capture %s: code=%d body=%s", word, rec.Code, rec.Body)
}
var w Word
if err := json.Unmarshal(rec.Body.Bytes(), &w); err != nil {
t.Fatalf("decode %s: %v", word, err)
}
return w
}
if got := capture("comum", `{"word":"comum","doc_id":"`+pairDoc+`"}`); got.Lang != "pair" {
t.Fatalf("word from a flipped document: lang=%q, want pair", got.Lang)
}
if got := capture("reception", `{"word":"reception","doc_id":"`+enDoc+`"}`); got.Lang != "en" {
t.Fatalf("word from an English document: lang=%q, want en", got.Lang)
}
if got := capture("orphan", `{"word":"orphan"}`); got.Lang != "" {
t.Fatalf("word with no document: lang=%q, want empty", got.Lang)
}
// A client that names a language is ignored: the document is the authority,
// the same way runPass never lets the request pick its own target.
if got := capture("comum", `{"word":"comum","lang":"en","doc_id":"`+pairDoc+`"}`); got.Lang != "pair" {
t.Fatalf("client-supplied lang should not win: lang=%q, want pair", got.Lang)
}
}
// TestRecaptureOutsideADocumentKeepsItsLanguage pins the one asymmetry in the
// upsert. Re-looking-up a word refreshes its context, but a lookup made with no
// document carries no verdict — and relabelling a Portuguese card English
// because she checked the word again from the search box would silently move it
// to the wrong voice. lang travels with doc_id, or it doesn't travel.
func TestRecaptureOutsideADocumentKeepsItsLanguage(t *testing.T) {
srv, database := newTestServer(t)
var docID string
if err := database.QueryRow(
`INSERT INTO documents (user_id, content_text, doc_lang) VALUES (?, 'olá', 'pair') RETURNING id`,
db.LocalUserID,
).Scan(&docID); err != nil {
t.Fatalf("seed doc: %v", err)
}
if rec := do(t, srv, http.MethodPost, "/vocab",
`{"word":"saudade","gloss":"","doc_id":"`+docID+`"}`); rec.Code != http.StatusCreated {
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
}
rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"saudade","gloss":"longing"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("recapture: code=%d body=%s", rec.Code, rec.Body)
}
var w Word
_ = json.Unmarshal(rec.Body.Bytes(), &w)
if w.Lang != "pair" {
t.Fatalf("recapture outside a document: lang=%q, want pair held", w.Lang)
}
if w.Gloss != "longing" {
t.Fatalf("recapture should still refresh the gloss, got %q", w.Gloss)
}
}