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
157 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|