Files
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)
}
}