Files
prosolis 466055020f 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
2026-07-28 23:45:26 -07:00

244 lines
9.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package suggestions
import (
"net/http"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// seedSuggestion writes one pending suggestion against the seeded doc, after
// replacing the doc's text so the sentence around `original` is under the test's
// control.
func seedSuggestion(t *testing.T, h *Handler, docID, text, sType, original, replacement, explanation string) string {
t.Helper()
if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`, text, docID); err != nil {
t.Fatalf("set content: %v", err)
}
var id string
err := h.DB.QueryRow(
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, status)
VALUES (?, 0, 0, ?, ?, ?, ?, 'pending') RETURNING id`,
docID, original, replacement, explanation, sType,
).Scan(&id)
if err != nil {
t.Fatalf("seed suggestion: %v", err)
}
return id
}
type card struct {
word, definition, example string
interval int
}
func gardenCards(t *testing.T, h *Handler) []card {
t.Helper()
rows, err := h.DB.Query(
`SELECT word, definition, example, interval_days FROM vocab_words WHERE user_id = ? ORDER BY word`,
db.LocalUserID,
)
if err != nil {
t.Fatalf("read garden: %v", err)
}
defer rows.Close()
var out []card
for rows.Next() {
var c card
if err := rows.Scan(&c.word, &c.definition, &c.example, &c.interval); err != nil {
t.Fatalf("scan: %v", err)
}
out = append(out, c)
}
return out
}
// TestAcceptedCollocationIsPlanted walks the whole hand-over: a collocation the
// writer accepts becomes a phrase card whose example is the *corrected*
// sentence, so the flashcard quizzes the phrasing she kept.
func TestAcceptedCollocationIsPlanted(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
id := seedSuggestion(t, h, docID,
"Yesterday was hard. I had to do a decision about the job. Then I slept.",
db.SuggestionTypeCollocation, "do a decision", "make a decision",
"English pairs “make” with “decision”.")
if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent {
t.Fatalf("accept: got %d, want 204", rec.Code)
}
cards := gardenCards(t, h)
if len(cards) != 1 {
t.Fatalf("garden has %d cards, want 1: %+v", len(cards), cards)
}
got := cards[0]
if got.word != "make a decision" {
t.Errorf("word = %q, want %q", got.word, "make a decision")
}
if got.example != "I had to make a decision about the job." {
t.Errorf("example = %q — want the corrected sentence, bounded to its own sentence", got.example)
}
if got.definition != "English pairs “make” with “decision”." {
t.Errorf("definition = %q, want the explanation", got.definition)
}
if got.interval != 1 {
t.Errorf("interval_days = %d, want 1 (due tomorrow, like a fresh capture)", got.interval)
}
}
// TestOnlyCollocationsArePlanted: the other families correct this sentence and
// hand over nothing reusable. A dismissed collocation is not a lesson either.
func TestOnlyCollocationsArePlanted(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
grammar := seedSuggestion(t, h, docID, "I has two apples.",
db.SuggestionTypeGrammar, "I has", "I have", "Subjectverb agreement.")
if rec := do(t, srv, http.MethodPost, "/suggestions/"+grammar+"/accept", ""); rec.Code != http.StatusNoContent {
t.Fatalf("accept grammar: got %d", rec.Code)
}
dismissed := seedSuggestion(t, h, docID, "We must take a photo of it.",
db.SuggestionTypeCollocation, "do a photo", "take a photo", "Photos are taken.")
if rec := do(t, srv, http.MethodPost, "/suggestions/"+dismissed+"/dismiss", ""); rec.Code != http.StatusNoContent {
t.Fatalf("dismiss: got %d", rec.Code)
}
if cards := gardenCards(t, h); len(cards) != 0 {
t.Fatalf("garden grew %d card(s) from a grammar fix and a dismissal: %+v", len(cards), cards)
}
}
// TestPlantingIsIdempotentAndNeverResets: accepting the same chunk again is
// evidence it's still being learned — the worst possible response is to wipe the
// card's first context and the schedule it has been climbing.
func TestPlantingIsIdempotentAndNeverResets(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
first := seedSuggestion(t, h, docID, "I had to do a decision.",
db.SuggestionTypeCollocation, "do a decision", "make a decision", "First explanation.")
if rec := do(t, srv, http.MethodPost, "/suggestions/"+first+"/accept", ""); rec.Code != http.StatusNoContent {
t.Fatalf("accept: got %d", rec.Code)
}
// The card climbs a little.
if _, err := h.DB.Exec(
`UPDATE vocab_words SET reps = 3, interval_days = 7 WHERE user_id = ? AND word = 'make a decision'`,
db.LocalUserID,
); err != nil {
t.Fatalf("advance card: %v", err)
}
second := seedSuggestion(t, h, docID, "Later I must do a decision again.",
db.SuggestionTypeCollocation, "do a decision", "make a decision", "Second explanation.")
if rec := do(t, srv, http.MethodPost, "/suggestions/"+second+"/accept", ""); rec.Code != http.StatusNoContent {
t.Fatalf("accept again: got %d", rec.Code)
}
cards := gardenCards(t, h)
if len(cards) != 1 {
t.Fatalf("garden has %d cards, want 1 (one chunk, one card)", len(cards))
}
if cards[0].definition != "First explanation." {
t.Errorf("definition = %q — the existing card should win", cards[0].definition)
}
if cards[0].example != "I had to make a decision." {
t.Errorf("example = %q — the first context should survive", cards[0].example)
}
if cards[0].interval != 7 {
t.Errorf("interval_days = %d, want 7 — progress must not be reset", cards[0].interval)
}
}
// TestSentenceRewriteIsNotAPhraseCard: a "collocation" long enough to be a
// rewritten sentence makes a miserable flashcard, so it is dropped rather than
// planted — and the accept still succeeds.
func TestSentenceRewriteIsNotAPhraseCard(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
long := "I would like to take this opportunity to thank you for everything"
id := seedSuggestion(t, h, docID, "I want thank you for everything.",
db.SuggestionTypeCollocation, "I want thank you for everything", long, "More natural.")
if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent {
t.Fatalf("accept: got %d, want 204 — a skipped card must never fail the accept", rec.Code)
}
if cards := gardenCards(t, h); len(cards) != 0 {
t.Fatalf("planted a sentence as a phrase card: %+v", cards)
}
}
func TestCorrectedSentence(t *testing.T) {
const text = "One thing. I had to do a decision fast! Another thing."
cases := []struct {
name, original, replacement, want string
}{
{"bounded to its sentence", "do a decision", "make a decision", "I had to make a decision fast!"},
{"original no longer present", "do a choice", "make a choice", ""},
{"empty original", "", "make a decision", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := correctedSentence(text, tc.original, tc.replacement); got != tc.want {
t.Errorf("correctedSentence = %q, want %q", got, tc.want)
}
})
}
// A document with no terminator at all is one sentence, and still works.
if got := correctedSentence("i had to do a decision", "do a decision", "make a decision"); got != "i had to make a decision" {
t.Errorf("unterminated doc: got %q", got)
}
}
// TestPlantedPhraseCarriesTheDocumentLanguage: a chunk planted out of a document
// written in her own language is a card in that language. The collocation pass
// itself deliberately did not flip in Phase 28 — its prompt is per-language
// knowledge, not framing — but the phrase it hands over is still lifted from her
// prose, so the card has to know what language that prose was in or the garden
// will read it aloud in the wrong voice.
func TestPlantedPhraseCarriesTheDocumentLanguage(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
if _, err := h.DB.Exec(`UPDATE documents SET doc_lang = 'pair' WHERE id = ?`, docID); err != nil {
t.Fatalf("set doc_lang: %v", err)
}
id := seedSuggestion(t, h, docID,
"Ontem foi difícil. Tive de tomar uma decisão sobre o trabalho.",
db.SuggestionTypeCollocation, "tomar uma decisão", "tomar uma decisão",
"Em português diz-se “tomar” uma decisão.")
if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent {
t.Fatalf("accept: got %d, want 204", rec.Code)
}
var lang string
if err := h.DB.QueryRow(
`SELECT lang FROM vocab_words WHERE user_id = ? AND word = ?`,
db.LocalUserID, "tomar uma decisão",
).Scan(&lang); err != nil {
t.Fatalf("read planted card: %v", err)
}
if lang != "pair" {
t.Fatalf("planted card lang = %q, want pair", lang)
}
}
// TestPlantedPhraseOnAnEnglishDocumentIsUnchanged is the other half, and the one
// every account is on today: an English document plants an English card, and the
// empty backfill and 'en' both mean that.
func TestPlantedPhraseOnAnEnglishDocumentIsUnchanged(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
id := seedSuggestion(t, h, docID, "I had to do a decision about the job.",
db.SuggestionTypeCollocation, "do a decision", "make a decision",
"English pairs “make” with “decision”.")
if rec := do(t, srv, http.MethodPost, "/suggestions/"+id+"/accept", ""); rec.Code != http.StatusNoContent {
t.Fatalf("accept: got %d, want 204", rec.Code)
}
var lang string
if err := h.DB.QueryRow(
`SELECT lang FROM vocab_words WHERE user_id = ? AND word = ?`,
db.LocalUserID, "make a decision",
).Scan(&lang); err != nil {
t.Fatalf("read planted card: %v", err)
}
if lang == "pair" {
t.Fatalf("planted card lang = %q on an English document, want en or empty", lang)
}
}