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
244 lines
9.6 KiB
Go
244 lines
9.6 KiB
Go
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)
|
||
}
|
||
}
|