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", "Subject–verb 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) } }