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
342 lines
13 KiB
Go
342 lines
13 KiB
Go
package vocab
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||
)
|
||
|
||
func newTestServer(t *testing.T) (http.Handler, *db.DB) {
|
||
t.Helper()
|
||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||
if err != nil {
|
||
t.Fatalf("open db: %v", err)
|
||
}
|
||
t.Cleanup(func() { database.Close() })
|
||
r := chi.NewRouter()
|
||
r.Mount("/vocab", New(database).Routes())
|
||
|
||
// Behind the same auth middleware main.go installs: handlers resolve the
|
||
// caller from the request context, so a bare router would see no user and
|
||
// every user-scoped query would match nothing.
|
||
authed := auth.Middleware(auth.StaticResolver(db.LocalUserID))(r)
|
||
return authed, database
|
||
}
|
||
|
||
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
var r *http.Request
|
||
if body != "" {
|
||
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||
} else {
|
||
r = httptest.NewRequest(method, path, nil)
|
||
}
|
||
rec := httptest.NewRecorder()
|
||
srv.ServeHTTP(rec, r)
|
||
return rec
|
||
}
|
||
|
||
// TestCaptureUpsertAndReview walks the garden lifecycle: capture a word (lands
|
||
// in the future, not yet due), re-capture refreshes context without resetting
|
||
// schedule, a forced-due word shows up in /due, a review reschedules it, and
|
||
// delete removes it.
|
||
func TestCaptureUpsertAndReview(t *testing.T) {
|
||
srv, database := newTestServer(t)
|
||
|
||
// Capture a new word → 201, due tomorrow (interval 1), not in /due yet.
|
||
rec := do(t, srv, http.MethodPost, "/vocab",
|
||
`{"word":"serendipity","gloss":"机缘巧合","phonetic":"/ˌserənˈdɪpəti/","example":"What a serendipity."}`)
|
||
if rec.Code != http.StatusCreated {
|
||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||
}
|
||
var w Word
|
||
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||
if w.ID == "" || w.Word != "serendipity" || w.Gloss != "机缘巧合" {
|
||
t.Fatalf("captured word wrong: %+v", w)
|
||
}
|
||
if w.IntervalDays != 1 || w.Reps != 0 {
|
||
t.Fatalf("new word should start at interval 1, reps 0: %+v", w)
|
||
}
|
||
|
||
// Not due yet (due tomorrow).
|
||
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||
var due []Word
|
||
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||
if len(due) != 0 {
|
||
t.Fatalf("freshly-captured word should not be due yet, got %d", len(due))
|
||
}
|
||
|
||
// Re-capture with a new gloss → still one row (idempotent upsert), refreshed.
|
||
rec = do(t, srv, http.MethodPost, "/vocab",
|
||
`{"word":"serendipity","gloss":"意外的好运","phonetic":"/x/","example":""}`)
|
||
if rec.Code != http.StatusCreated {
|
||
t.Fatalf("re-capture: code=%d body=%s", rec.Code, rec.Body)
|
||
}
|
||
rec = do(t, srv, http.MethodGet, "/vocab", "")
|
||
var all []Word
|
||
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||
if len(all) != 1 {
|
||
t.Fatalf("re-capture should not add a row, got %d", len(all))
|
||
}
|
||
if all[0].Gloss != "意外的好运" {
|
||
t.Fatalf("gloss should refresh on re-capture, got %q", all[0].Gloss)
|
||
}
|
||
if all[0].Example != "What a serendipity." {
|
||
t.Fatalf("empty example should not clobber the prior one, got %q", all[0].Example)
|
||
}
|
||
|
||
// Force it due now, then it appears in /due.
|
||
if _, err := database.Exec(`UPDATE vocab_words SET due_at = datetime('now','-1 hour')`); err != nil {
|
||
t.Fatalf("force due: %v", err)
|
||
}
|
||
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||
if len(due) != 1 {
|
||
t.Fatalf("forced-due word should be in /due, got %d", len(due))
|
||
}
|
||
|
||
// Review "good" → reschedules forward (interval 3 = second ladder rung, since
|
||
// reps was 0 and a capture set interval 1 but reps 0; first good → rung 1 = 1).
|
||
rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"good"}`)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("review: code=%d body=%s", rec.Code, rec.Body)
|
||
}
|
||
var reviewed Word
|
||
_ = json.Unmarshal(rec.Body.Bytes(), &reviewed)
|
||
if reviewed.Reps != 1 || reviewed.IntervalDays != 1 {
|
||
t.Fatalf("first good review should be reps 1, interval 1: %+v", reviewed)
|
||
}
|
||
if reviewed.LastReviewed == nil {
|
||
t.Fatalf("review should stamp last_reviewed")
|
||
}
|
||
|
||
// After a forward review it's no longer due.
|
||
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||
if len(due) != 0 {
|
||
t.Fatalf("reviewed word should leave /due, got %d", len(due))
|
||
}
|
||
|
||
// Bad grade → 400.
|
||
if rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"nope"}`); rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("bad grade: want 400, got %d", rec.Code)
|
||
}
|
||
|
||
// Delete → 204, garden empty.
|
||
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNoContent {
|
||
t.Fatalf("delete: code=%d", rec.Code)
|
||
}
|
||
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNotFound {
|
||
t.Fatalf("re-delete: want 404, got %d", rec.Code)
|
||
}
|
||
}
|
||
|
||
// TestCaptureValidation rejects an empty word.
|
||
func TestCaptureValidation(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" "}`); rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("empty word: want 400, got %d", rec.Code)
|
||
}
|
||
}
|
||
|
||
// TestCaptureStoresDefinitionFallback proves a word with an English definition
|
||
// but no Chinese gloss still round-trips its definition, so review has a meaning
|
||
// to reveal instead of a blank card.
|
||
func TestCaptureStoresDefinitionFallback(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
rec := do(t, srv, http.MethodPost, "/vocab",
|
||
`{"word":"ineffable","definition":"too great to be expressed in words"}`)
|
||
if rec.Code != http.StatusCreated {
|
||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||
}
|
||
var w Word
|
||
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||
if w.Gloss != "" {
|
||
t.Fatalf("expected no gloss, got %q", w.Gloss)
|
||
}
|
||
if w.Definition != "too great to be expressed in words" {
|
||
t.Fatalf("definition not stored, got %q", w.Definition)
|
||
}
|
||
}
|
||
|
||
// TestCaptureCaseInsensitive proves "Apple" (sentence start) and "apple"
|
||
// (mid-line) land in the SAME garden card — capture normalizes to lower+trim
|
||
// like the lexicon, so the idempotent upsert isn't defeated by casing.
|
||
func TestCaptureCaseInsensitive(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"Apple","gloss":"苹果"}`); rec.Code != http.StatusCreated {
|
||
t.Fatalf("capture Apple: code=%d body=%s", rec.Code, rec.Body)
|
||
}
|
||
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" apple ","gloss":"苹果"}`); rec.Code != http.StatusCreated {
|
||
t.Fatalf("capture apple: code=%d body=%s", rec.Code, rec.Body)
|
||
}
|
||
rec := do(t, srv, http.MethodGet, "/vocab", "")
|
||
var all []Word
|
||
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||
if len(all) != 1 {
|
||
t.Fatalf("Apple/apple should be one card, got %d", len(all))
|
||
}
|
||
if all[0].Word != "apple" {
|
||
t.Fatalf("word should be normalized to lowercase, got %q", all[0].Word)
|
||
}
|
||
}
|
||
|
||
// TestCaptureUnknownDocID rejects an unowned/stale doc_id with a clean 400
|
||
// rather than letting it hit the foreign key and leak a raw 500.
|
||
func TestCaptureUnknownDocID(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
rec := do(t, srv, http.MethodPost, "/vocab",
|
||
`{"word":"quixotic","gloss":"不切实际的","doc_id":"does-not-exist"}`)
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("unknown doc_id: want 400, got %d body=%s", rec.Code, rec.Body)
|
||
}
|
||
// A blank doc_id is treated as none, not an error.
|
||
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"limpid","gloss":"清澈的","doc_id":""}`); rec.Code != http.StatusCreated {
|
||
t.Fatalf("blank doc_id should be accepted as none: code=%d body=%s", rec.Code, rec.Body)
|
||
}
|
||
}
|
||
|
||
// TestCaptureClampsLongFields proves over-long fields are clamped (not rejected)
|
||
// so a lookup never fails on length, and the word key stays bounded.
|
||
func TestCaptureClampsLongFields(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
longWord := strings.Repeat("a", maxWordLen+50)
|
||
longDef := strings.Repeat("x", maxDefinitionLen+50)
|
||
rec := do(t, srv, http.MethodPost, "/vocab",
|
||
`{"word":"`+longWord+`","definition":"`+longDef+`"}`)
|
||
if rec.Code != http.StatusCreated {
|
||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||
}
|
||
var w Word
|
||
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||
if len([]rune(w.Word)) != maxWordLen {
|
||
t.Fatalf("word should clamp to %d runes, got %d", maxWordLen, len([]rune(w.Word)))
|
||
}
|
||
if len([]rune(w.Definition)) != maxDefinitionLen {
|
||
t.Fatalf("definition should clamp to %d runes, got %d", maxDefinitionLen, len([]rune(w.Definition)))
|
||
}
|
||
}
|
||
|
||
// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
|
||
// garden when its source document is removed.
|
||
func TestDocLinkSurvivesDocDelete(t *testing.T) {
|
||
srv, database := newTestServer(t)
|
||
var docID string
|
||
if err := database.QueryRow(
|
||
`INSERT INTO documents (user_id, content_text) VALUES (?, 'hi') RETURNING id`, db.LocalUserID,
|
||
).Scan(&docID); err != nil {
|
||
t.Fatalf("seed doc: %v", err)
|
||
}
|
||
rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"ephemeral","gloss":"短暂的","doc_id":"`+docID+`"}`)
|
||
if rec.Code != http.StatusCreated {
|
||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||
}
|
||
if _, err := database.Exec(`DELETE FROM documents WHERE id = ?`, docID); err != nil {
|
||
t.Fatalf("delete doc: %v", err)
|
||
}
|
||
rec = do(t, srv, http.MethodGet, "/vocab", "")
|
||
var all []Word
|
||
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||
if len(all) != 1 {
|
||
t.Fatalf("word should survive doc deletion, got %d", len(all))
|
||
}
|
||
if all[0].DocID != nil {
|
||
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)
|
||
}
|
||
}
|