Files
petal/internal/suggestions/growth_test.go
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

157 lines
5.9 KiB
Go

package suggestions
import (
"encoding/json"
"net/http"
"strconv"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// resolved seeds one already-settled suggestion, dated `daysAgo` at the moment
// she decided it (the journal reads decisions, not proposals).
func resolved(t *testing.T, h *Handler, docID, status, original, replacement string, daysAgo int) {
t.Helper()
_, err := h.DB.Exec(
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at, resolved_at)
VALUES (?, 0, 0, ?, ?, '', 'collocation', ?, datetime('now', ?), datetime('now', ?))`,
docID, original, replacement, status,
"-"+strconv.Itoa(daysAgo)+" days", "-"+strconv.Itoa(daysAgo)+" days",
)
if err != nil {
t.Fatalf("seed resolved suggestion: %v", err)
}
}
func seedDoc(t *testing.T, h *Handler, userID, text string) string {
t.Helper()
var id string
if err := h.DB.QueryRow(
`INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`, userID, text,
).Scan(&id); err != nil {
t.Fatalf("seed doc: %v", err)
}
return id
}
func readJournal(t *testing.T, srv http.Handler) Journal {
t.Helper()
rec := do(t, srv, http.MethodGet, "/suggestions/growth", "")
if rec.Code != http.StatusOK {
t.Fatalf("growth: got %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
var j Journal
if err := json.Unmarshal(rec.Body.Bytes(), &j); err != nil {
t.Fatalf("decode journal: %v", err)
}
return j
}
// TestJournalIsEmptyForANewWriter: nothing to report reports nothing. Empty
// lists, not nulls, so the frontend never has to guess.
func TestJournalIsEmptyForANewWriter(t *testing.T) {
srv, _, _ := newTestServer(t, &stubClient{})
j := readJournal(t, srv)
if j.Kept != 0 || j.KeptBefore != 0 || len(j.Stuck) != 0 || len(j.Faded) != 0 {
t.Fatalf("new writer got a journal: %+v", j)
}
}
// TestKeptComparesHerToHerOwnPastSelf.
func TestKeptCountsTwoWindows(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
for i := 0; i < 3; i++ {
resolved(t, h, docID, "accepted", "do a decision", "make a decision", 5)
}
resolved(t, h, docID, "accepted", "big rain", "heavy rain", 40)
resolved(t, h, docID, "rejected", "no thanks", "no, thank you", 5) // decisions kept only
resolved(t, h, docID, "accepted", "long ago", "long since", 200) // outside both windows
j := readJournal(t, srv)
if j.Kept != 3 {
t.Errorf("Kept = %d, want 3", j.Kept)
}
if j.KeptBefore != 1 {
t.Errorf("KeptBefore = %d, want 1", j.KeptBefore)
}
}
// TestStuckNeedsASecondDocument: a phrase sitting in the one document it was
// applied to has not stuck — it's just the edit, where she left it. A second
// document is her reaching for it herself, which is the claim the line makes.
func TestStuckNeedsASecondDocument(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`,
"I had to make a decision.", docID); err != nil {
t.Fatalf("set content: %v", err)
}
resolved(t, h, docID, "accepted", "do a decision", "make a decision", 10)
resolved(t, h, docID, "accepted", "do a photo", "take a photo", 10)
if j := readJournal(t, srv); len(j.Stuck) != 0 {
t.Fatalf("one document counted as sticking: %+v", j.Stuck)
}
// She uses it again, elsewhere, on her own.
seedDoc(t, h, db.LocalUserID, "Later I had to Make A Decision about the flat.")
j := readJournal(t, srv)
if len(j.Stuck) != 1 {
t.Fatalf("Stuck = %+v, want just the phrase she reused", j.Stuck)
}
if j.Stuck[0].Phrase != "make a decision" || j.Stuck[0].Docs != 2 {
t.Errorf("Stuck[0] = %+v, want {make a decision 2} (case-insensitive)", j.Stuck[0])
}
}
// TestFadedNeedsRecentWriting is the guard that keeps this feature honest: a
// month away from Petal must never be reported back as progress.
func TestFadedNeedsRecentWriting(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 60)
resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 55)
if j := readJournal(t, srv); len(j.Faded) != 0 {
t.Fatalf("silence reported as growth: %+v", j.Faded)
}
// She has been writing again this month — now the absence means something.
resolved(t, h, docID, "accepted", "big rain", "heavy rain", 3)
j := readJournal(t, srv)
if len(j.Faded) != 1 || j.Faded[0].Pattern != "在 the morning" || j.Faded[0].Times != 2 {
t.Fatalf("Faded = %+v, want the pattern she stopped needing (twice, back then)", j.Faded)
}
}
// TestFadedExcludesWhatStillHappens: a pattern corrected again this month has
// not faded, however often it came up before.
func TestFadedExcludesWhatStillHappens(t *testing.T) {
srv, docID, h := newTestServer(t, &stubClient{})
resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 60)
resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 55)
resolved(t, h, docID, "accepted", "在 the morning", "in the morning", 2)
if j := readJournal(t, srv); len(j.Faded) != 0 {
t.Fatalf("Faded = %+v, want empty — it still happens", j.Faded)
}
}
// TestJournalIsPerWriter: another account's learning is never anyone else's
// journal, and the only comparison Petal draws is with her own past self.
func TestJournalIsPerWriter(t *testing.T) {
srv, _, h := newTestServer(t, &stubClient{})
if _, err := h.DB.Exec(`INSERT INTO users (id, email) VALUES ('bob', 'bob@example.com')`); err != nil {
t.Fatalf("seed user: %v", err)
}
bobDoc := seedDoc(t, h, "bob", "Bob had to make a decision.")
seedDoc(t, h, "bob", "Bob will make a decision again.")
resolved(t, h, bobDoc, "accepted", "do a decision", "make a decision", 5)
resolved(t, h, bobDoc, "accepted", "big rain", "heavy rain", 60)
resolved(t, h, bobDoc, "accepted", "big rain", "heavy rain", 55)
j := readJournal(t, srv)
if j.Kept != 0 || j.KeptBefore != 0 || len(j.Stuck) != 0 || len(j.Faded) != 0 {
t.Fatalf("bob's learning leaked into the local user's journal: %+v", j)
}
}