Files
petal/internal/suggestions/plant_test.go
T
prosolis e9b8595456 Let the garden keep what she was given, not only what she sought
Two halves of the same idea, both read out of work Petal already
records.

Planting: an accepted collocation is a learnable chunk, so it becomes a
phrase card. The scheduler didn't need to know — a three-word chunk
climbs the ladder exactly like a looked-up word. What needed care was
deciding what *isn't* a chunk (single words are word choice; a
six-word-plus "collocation" is a rewritten sentence, and sentences make
miserable flashcards), and that the example must be the *corrected*
sentence — the stored draft still holds the phrasing she just left
behind. Re-accepting the same chunk leaves the existing card alone
rather than resetting a schedule it has been climbing. The whole thing
is best-effort: accepting an edit must never fail because a flashcard
couldn't be made.

The growth journal: kept this month beside kept the month before, the
phrasing that stuck, the patterns that faded. The queries were the easy
part; the honesty is the feature. "Stuck" needs the phrase in a *second*
document, because one document is just the edit where she left it.
"Faded" says nothing at all unless she has been writing lately —
otherwise a month away from Petal comes back to her as progress, which
is the one way this could lie. And a suggestion had to start recording
when she *decided* it, not when the model proposed it, so 0012 adds
resolved_at and backfills the old rows to their created_at.

It lives as a second tab in the garden, and it feeds the kitten: after
an accept she now sometimes hears something true of her alone, once per
line, half the time, never waited for.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 14:16:59 -07:00

187 lines
7.2 KiB
Go
Raw 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)
}
}