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
This commit is contained in:
@@ -459,6 +459,24 @@ CREATE TABLE personal_words (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, lang, word)
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// The growth journal reads the suggestions table as a record of what the
|
||||
// writer has been learning, and that reading only works if a row is dated
|
||||
// by *her decision* rather than by the model's proposal. `created_at` is
|
||||
// when a checkpoint offered the edit; a suggestion offered in April and
|
||||
// accepted in June is June's growth, not April's.
|
||||
//
|
||||
// Existing rows are backfilled to created_at — which is exactly the
|
||||
// approximation the journal would have had to make anyway, and is very
|
||||
// nearly right in practice since edits are settled minutes after a
|
||||
// checkpoint. Only pending rows keep a NULL: nothing has been decided.
|
||||
name: "0012_suggestion_resolved_at",
|
||||
stmt: `
|
||||
ALTER TABLE suggestions ADD COLUMN resolved_at DATETIME;
|
||||
UPDATE suggestions SET resolved_at = created_at WHERE status != 'pending';
|
||||
CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,3 +83,70 @@ func TestOpenMigratesAndSeeds(t *testing.T) {
|
||||
t.Errorf("expected exactly 1 local user after reopen, got %d", users)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvedAtBackfill runs migration 0012 against a database that predates
|
||||
// it, which is the only shape that matters: on the live box the suggestions
|
||||
// table is years of settled edits with no resolved_at to their name. Backfilling
|
||||
// to created_at is exactly the approximation the growth journal would otherwise
|
||||
// have had to make, and a pending row must stay NULL — nothing has been decided.
|
||||
func TestResolvedAtBackfill(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "old.db")
|
||||
d, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
|
||||
// Rewind to the state before 0012: drop the column and forget the migration.
|
||||
if _, err := d.Exec(`DROP INDEX idx_suggestions_resolved`); err != nil {
|
||||
t.Fatalf("rewind index: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`ALTER TABLE suggestions DROP COLUMN resolved_at`); err != nil {
|
||||
t.Fatalf("rewind schema: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`DELETE FROM schema_migrations WHERE name = '0012_suggestion_resolved_at'`); err != nil {
|
||||
t.Fatalf("rewind migration record: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`INSERT INTO documents (id, user_id) VALUES ('d1', ?)`, LocalUserID); err != nil {
|
||||
t.Fatalf("insert document: %v", err)
|
||||
}
|
||||
for _, s := range []struct{ id, status string }{
|
||||
{"s-old", "accepted"},
|
||||
{"s-open", "pending"},
|
||||
} {
|
||||
if _, err := d.Exec(
|
||||
`INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
||||
VALUES (?, 'd1', 0, 3, 'teh', 'the', 'x', 'grammar', ?, '2026-01-02 03:04:05')`,
|
||||
s.id, s.status,
|
||||
); err != nil {
|
||||
t.Fatalf("seed %s: %v", s.id, err)
|
||||
}
|
||||
}
|
||||
d.Close()
|
||||
|
||||
d2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen (migrate): %v", err)
|
||||
}
|
||||
defer d2.Close()
|
||||
|
||||
// Compared against created_at read back the same way: the driver renders a
|
||||
// DATETIME column itself, so the assertion is "the same instant", not a
|
||||
// particular text format.
|
||||
var settled, created *string
|
||||
if err := d2.QueryRow(
|
||||
`SELECT resolved_at, created_at FROM suggestions WHERE id = 's-old'`,
|
||||
).Scan(&settled, &created); err != nil {
|
||||
t.Fatalf("read settled row: %v", err)
|
||||
}
|
||||
if settled == nil || created == nil || *settled != *created {
|
||||
t.Errorf("resolved_at = %v, want it backfilled from created_at (%v)", settled, created)
|
||||
}
|
||||
|
||||
var pending *string
|
||||
if err := d2.QueryRow(`SELECT resolved_at FROM suggestions WHERE id = 's-open'`).Scan(&pending); err != nil {
|
||||
t.Fatalf("read pending row: %v", err)
|
||||
}
|
||||
if pending != nil {
|
||||
t.Errorf("pending row got resolved_at = %v, want NULL — nothing was decided", *pending)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package suggestions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/auth"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
"gitea.parodia.dev/drwily/petal/internal/vocab"
|
||||
)
|
||||
|
||||
// The growth journal.
|
||||
//
|
||||
// The suggestions table already records everything this needs — it is purely a
|
||||
// read-side view, with no new capture and no model call. Two framing rules
|
||||
// decide what may appear here, and they are enforced in the SQL rather than left
|
||||
// to the copy:
|
||||
//
|
||||
// 1. It reports growth, never an error tally. Nothing counts what she got
|
||||
// wrong this month; the signals are things that *stopped* happening and
|
||||
// phrasing that *stuck*.
|
||||
// 2. It only ever compares the writer to her own past self. There is no
|
||||
// target, no average, no other user anywhere in these queries.
|
||||
//
|
||||
// A quiet month is quiet: every signal below is omitted rather than softened
|
||||
// when the data isn't there, because an invented milestone is worse than none.
|
||||
|
||||
// Journal is one writer's growth over the recent windows.
|
||||
type Journal struct {
|
||||
// Kept / KeptBefore are edits she took on board in the last 30 days and in
|
||||
// the 30 before that — her own past self, the only comparison offered.
|
||||
Kept int `json:"kept"`
|
||||
KeptBefore int `json:"kept_before"`
|
||||
// Stuck: phrasing she was given that now turns up across her own writing.
|
||||
Stuck []Chunk `json:"stuck"`
|
||||
// Faded: things she used to need fixing and hasn't, recently.
|
||||
Faded []Fade `json:"faded"`
|
||||
}
|
||||
|
||||
// Chunk is a phrase that has stuck: it appears in Docs of her documents now.
|
||||
type Chunk struct {
|
||||
Phrase string `json:"phrase"`
|
||||
Docs int `json:"docs"`
|
||||
}
|
||||
|
||||
// Fade is a pattern that has stopped appearing. Times is how often it came up
|
||||
// during the earlier window — context for "and not since", never a scoreboard.
|
||||
type Fade struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Times int `json:"times"`
|
||||
}
|
||||
|
||||
// Journal windows, in days. `recent` is the month being reported on; `history`
|
||||
// reaches back far enough that a pattern's absence means something (one quiet
|
||||
// fortnight doesn't).
|
||||
const (
|
||||
recentDays = 30
|
||||
historyDays = 120
|
||||
maxSignals = 3 // per list: a journal is a couple of warm lines, not a report
|
||||
)
|
||||
|
||||
// growth serves GET /api/suggestions/growth.
|
||||
func (h *Handler) growth(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.UserID(r.Context())
|
||||
j := Journal{Stuck: []Chunk{}, Faded: []Fade{}}
|
||||
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT
|
||||
sum(CASE WHEN s.resolved_at >= datetime('now', '-30 days') THEN 1 ELSE 0 END),
|
||||
sum(CASE WHEN s.resolved_at < datetime('now', '-30 days')
|
||||
AND s.resolved_at >= datetime('now', '-60 days') THEN 1 ELSE 0 END)
|
||||
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||
WHERE d.user_id = ? AND s.status = 'accepted' AND s.resolved_at IS NOT NULL`,
|
||||
userID,
|
||||
).Scan(&nullInt{&j.Kept}, &nullInt{&j.KeptBefore})
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
stuck, err := h.stuck(userID)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
j.Stuck = stuck
|
||||
|
||||
faded, err := h.faded(userID)
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
j.Faded = faded
|
||||
|
||||
httputil.WriteJSON(w, http.StatusOK, j)
|
||||
}
|
||||
|
||||
// stuck finds accepted phrasing that now appears in more than one of her own
|
||||
// documents. One document is just the edit itself, still sitting where it was
|
||||
// applied; a second is her reaching for the phrase on her own, which is the
|
||||
// whole claim the line makes.
|
||||
func (h *Handler) stuck(userID string) ([]Chunk, error) {
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT DISTINCT s.replacement
|
||||
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||
WHERE d.user_id = ? AND s.status = 'accepted'
|
||||
AND s.resolved_at >= datetime('now', '-120 days')
|
||||
AND trim(s.replacement) != ''
|
||||
ORDER BY s.resolved_at DESC
|
||||
LIMIT 40`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// vocab.PhraseKey is the same definition of "a learnable chunk" the garden
|
||||
// plants, so the journal and the garden can never disagree about what counts.
|
||||
var phrases []string
|
||||
for rows.Next() {
|
||||
var replacement string
|
||||
if err := rows.Scan(&replacement); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key := vocab.PhraseKey(replacement); key != "" {
|
||||
phrases = append(phrases, key)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := []Chunk{}
|
||||
for _, p := range phrases {
|
||||
var docs int
|
||||
if err := h.DB.QueryRow(
|
||||
`SELECT count(*) FROM documents WHERE user_id = ? AND instr(lower(content_text), ?) > 0`,
|
||||
userID, p,
|
||||
).Scan(&docs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if docs >= 2 {
|
||||
out = append(out, Chunk{Phrase: p, Docs: docs})
|
||||
}
|
||||
}
|
||||
sortDesc(out, func(c Chunk) int { return c.Docs })
|
||||
return trim(out, maxSignals), nil
|
||||
}
|
||||
|
||||
// faded finds patterns she used to be corrected on during the earlier part of
|
||||
// the history window and hasn't been since.
|
||||
//
|
||||
// The guard that makes this honest: it says nothing at all unless she has
|
||||
// actually been writing lately. Without it, a month away from Petal would be
|
||||
// reported back to her as progress, which is the one way this feature could lie.
|
||||
func (h *Handler) faded(userID string) ([]Fade, error) {
|
||||
var wroteRecently int
|
||||
if err := h.DB.QueryRow(
|
||||
`SELECT count(*) FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||
WHERE d.user_id = ? AND s.resolved_at >= datetime('now', '-30 days')`,
|
||||
userID,
|
||||
).Scan(&wroteRecently); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wroteRecently == 0 {
|
||||
return []Fade{}, nil
|
||||
}
|
||||
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT lower(trim(s.original)) AS pattern, count(*) AS times
|
||||
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||
WHERE d.user_id = ? AND s.status = 'accepted'
|
||||
AND s.resolved_at < datetime('now', '-30 days')
|
||||
AND s.resolved_at >= datetime('now', '-120 days')
|
||||
AND trim(s.original) != ''
|
||||
AND pattern NOT IN (
|
||||
SELECT lower(trim(s2.original))
|
||||
FROM suggestions s2 JOIN documents d2 ON d2.id = s2.doc_id
|
||||
WHERE d2.user_id = ? AND s2.status = 'accepted'
|
||||
AND s2.resolved_at >= datetime('now', '-30 days'))
|
||||
GROUP BY pattern
|
||||
HAVING times >= 2
|
||||
ORDER BY times DESC
|
||||
LIMIT 3`,
|
||||
userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Fade{}
|
||||
for rows.Next() {
|
||||
var f Fade
|
||||
if err := rows.Scan(&f.Pattern, &f.Times); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// nullInt scans a possibly-NULL aggregate into an int (SUM over no rows is
|
||||
// NULL, which is a zero here, not an error).
|
||||
type nullInt struct{ dst *int }
|
||||
|
||||
func (n *nullInt) Scan(v any) error {
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
*n.dst = int(t)
|
||||
case nil:
|
||||
*n.dst = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortDesc[T any](s []T, key func(T) int) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && key(s[j]) > key(s[j-1]); j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trim[T any](s []T, n int) []T {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||
"gitea.parodia.dev/drwily/petal/internal/vocab"
|
||||
)
|
||||
|
||||
// Handler holds the dependencies for the checkpoint + suggestion routes. The
|
||||
@@ -60,6 +62,10 @@ func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||
// actions.
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
// The growth journal reads the same table these actions write, so it lives
|
||||
// here rather than growing its own mount. A literal segment, so it can never
|
||||
// be shadowed by an id.
|
||||
r.Get("/growth", h.growth)
|
||||
r.Post("/{id}/accept", h.accept)
|
||||
r.Post("/{id}/dismiss", h.dismiss)
|
||||
r.Post("/{id}/chat", h.chat)
|
||||
@@ -577,7 +583,7 @@ func (h *Handler) dismiss(w http.ResponseWriter, r *http.Request) {
|
||||
// no rows and surfaces as a 404.
|
||||
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
|
||||
res, err := h.DB.Exec(
|
||||
`UPDATE suggestions SET status = ?
|
||||
`UPDATE suggestions SET status = ?, resolved_at = datetime('now')
|
||||
WHERE id = ? AND status = ?
|
||||
AND doc_id IN (SELECT id FROM documents WHERE user_id = ?)`,
|
||||
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
|
||||
@@ -591,9 +597,72 @@ func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status strin
|
||||
httputil.ErrorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
||||
return
|
||||
}
|
||||
if status == db.SuggestionStatusAccepted {
|
||||
h.plant(chi.URLParam(r, "id"), auth.UserID(r.Context()))
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// plant grows an accepted collocation into a vocabulary-garden phrase card. It
|
||||
// runs after the status write and swallows its own errors: accepting an edit is
|
||||
// the thing the writer asked for, and it must not fail — or even feel slower —
|
||||
// because a flashcard couldn't be made.
|
||||
//
|
||||
// Only collocations are planted. The other families correct *this* sentence
|
||||
// ("their" → "there", a comma, a clearer clause); a collocation is the one that
|
||||
// hands over a reusable chunk, which is the only thing worth reviewing in a week.
|
||||
func (h *Handler) plant(id, userID string) {
|
||||
var s db.Suggestion
|
||||
var contentText string
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT s.type, s.original, s.replacement, s.explanation, s.doc_id, d.content_text
|
||||
FROM suggestions s JOIN documents d ON d.id = s.doc_id
|
||||
WHERE s.id = ? AND d.user_id = ?`,
|
||||
id, userID,
|
||||
).Scan(&s.Type, &s.Original, &s.Replacement, &s.Explanation, &s.DocID, &contentText)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("suggestions: could not read %s for planting: %v", id, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if s.Type != db.SuggestionTypeCollocation || strings.TrimSpace(s.Replacement) == "" {
|
||||
return
|
||||
}
|
||||
// The stored text is still the pre-accept draft — the client applies the
|
||||
// replacement in the editor. Correct the sentence here so the flashcard
|
||||
// quizzes the phrasing she is keeping, not the one she just left behind.
|
||||
docID := s.DocID
|
||||
if _, err := vocab.Plant(h.DB, userID, vocab.Phrase{
|
||||
Text: s.Replacement,
|
||||
Meaning: s.Explanation,
|
||||
Example: correctedSentence(contentText, s.Original, s.Replacement),
|
||||
DocID: &docID,
|
||||
}); err != nil {
|
||||
log.Printf("suggestions: could not plant %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// correctedSentence returns the sentence of contentText containing original,
|
||||
// with original swapped for replacement. Returns "" when the original isn't
|
||||
// found (the draft moved on) — a card with no example still reviews, just
|
||||
// without the cloze, so there's nothing to fall back to and nothing to guess.
|
||||
func correctedSentence(contentText, original, replacement string) string {
|
||||
idx := strings.Index(contentText, original)
|
||||
if original == "" || idx < 0 {
|
||||
return ""
|
||||
}
|
||||
start := strings.LastIndexAny(contentText[:idx], ".!?\n")
|
||||
end := strings.IndexAny(contentText[idx+len(original):], ".!?\n")
|
||||
if end < 0 {
|
||||
end = len(contentText)
|
||||
} else {
|
||||
end += idx + len(original) + 1 // keep the terminator
|
||||
}
|
||||
sentence := strings.TrimSpace(contentText[start+1 : end])
|
||||
return strings.Replace(sentence, original, replacement, 1)
|
||||
}
|
||||
|
||||
// locate finds the plaintext offsets of original within contentText. Returns
|
||||
// (-1, -1) when not found; the frontend anchors by string regardless, so a miss
|
||||
// here is non-fatal.
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package vocab
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Planting: the garden's second source.
|
||||
//
|
||||
// Capture (handlers.go) records words the writer *sought out*. Planting records
|
||||
// phrasing she was gently *given* — an accepted collocation like "make a
|
||||
// decision" is a learnable chunk exactly like a looked-up word, and the SM-2-lite
|
||||
// scheduler doesn't care that it's three words rather than one. Together the two
|
||||
// halves make the garden a record of both sides of learning.
|
||||
//
|
||||
// Everything here is best-effort by design: planting hangs off accepting a
|
||||
// suggestion, and that accept must succeed whether or not a card comes of it.
|
||||
|
||||
// Execer is the slice of *sql.DB (or *sql.Tx) that planting needs.
|
||||
type Execer interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// Phrase is one chunk to plant.
|
||||
type Phrase struct {
|
||||
Text string // the corrected phrasing, e.g. "make a decision"
|
||||
Meaning string // why it's better — the suggestion's explanation
|
||||
Example string // the sentence she met it in, already corrected
|
||||
DocID *string // where, so "where did I see this?" stays one tap
|
||||
}
|
||||
|
||||
// Phrase-card caps. A collocation is a short chunk; anything longer is a
|
||||
// rewritten sentence wearing a collocation's label, and a sentence makes a
|
||||
// miserable flashcard. Both bounds are deliberately tight — the cost of
|
||||
// skipping a real chunk is one missing card, the cost of planting a sentence is
|
||||
// a garden the writer stops trusting.
|
||||
const (
|
||||
maxPhraseRunes = 60
|
||||
maxPhraseWords = 6
|
||||
minPhraseWords = 2
|
||||
)
|
||||
|
||||
// PhraseKey normalizes a replacement into a garden key, or returns "" when the
|
||||
// text isn't a plantable chunk.
|
||||
//
|
||||
// Lowercasing matches capture's normalization, so a phrase and a looked-up word
|
||||
// share one UNIQUE(user_id, word) namespace rather than colliding sideways.
|
||||
// Single words are rejected on purpose: a one-word fix is word choice, and word
|
||||
// choice already reaches the garden through lookup — planting it here would give
|
||||
// it a card with no gloss and no phonetic, which reviews badly.
|
||||
func PhraseKey(text string) string {
|
||||
// Collapse all whitespace (a replacement can carry a newline from the
|
||||
// editor) so the key is stable and the word count is honest.
|
||||
s := strings.Join(strings.Fields(strings.ToLower(text)), " ")
|
||||
// Trim the punctuation a phrase picks up from the sentence around it, but
|
||||
// leave inner marks alone: "can't afford" and "in one's own time" are chunks.
|
||||
s = strings.Trim(s, `.,;:!?…"'“”‘’()[]`)
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || len([]rune(s)) > maxPhraseRunes {
|
||||
return ""
|
||||
}
|
||||
n := len(strings.Fields(s))
|
||||
if n < minPhraseWords || n > maxPhraseWords {
|
||||
return ""
|
||||
}
|
||||
// A chunk of pure digits or symbols ("12 000", "-- --") isn't vocabulary.
|
||||
if !strings.ContainsFunc(s, unicode.IsLetter) {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Plant adds a phrase card to the garden, due tomorrow like any fresh capture.
|
||||
// It reports whether a new card was created.
|
||||
//
|
||||
// ON CONFLICT DO NOTHING, unlike capture's refresh-the-context upsert: accepting
|
||||
// the same collocation again months later is evidence the chunk is still being
|
||||
// learned, and the last thing that should do is overwrite the card's first
|
||||
// context or disturb a schedule it has been climbing. An existing card wins.
|
||||
func Plant(ex Execer, userID string, p Phrase) (bool, error) {
|
||||
key := PhraseKey(p.Text)
|
||||
if key == "" {
|
||||
return false, nil
|
||||
}
|
||||
res, err := ex.Exec(
|
||||
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days)
|
||||
VALUES (?, ?, '', ?, '', ?, ?, datetime('now', '+1 day'), 1)
|
||||
ON CONFLICT(user_id, word) DO NOTHING`,
|
||||
userID, key,
|
||||
clamp(strings.TrimSpace(p.Meaning), maxDefinitionLen),
|
||||
clamp(strings.TrimSpace(p.Example), maxExampleLen),
|
||||
p.DocID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package vocab
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
func TestPhraseKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"make a decision", "make a decision"},
|
||||
{"Make A Decision", "make a decision"}, // shares one namespace with lookups
|
||||
{" make a\ndecision ", "make a decision"}, // the editor's whitespace
|
||||
{"“make a decision.”", "make a decision"}, // punctuation from the sentence around it
|
||||
{"can’t afford it", "can’t afford it"}, // inner marks are part of the chunk
|
||||
{"decision", ""}, // word choice, not a chunk — lookup's job
|
||||
{"", ""}, //
|
||||
{"...", ""}, //
|
||||
{"12 000", ""}, // digits aren't vocabulary
|
||||
{"a b c d e f g", ""}, // a clause wearing a chunk's label
|
||||
{"in one’s own good time again", "in one’s own good time again"}, // six words is still a chunk
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := PhraseKey(tc.in); got != tc.want {
|
||||
t.Errorf("PhraseKey(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
// The length cap counts runes, not bytes — otherwise a Portuguese chunk well
|
||||
// inside the limit would be dropped for being accented.
|
||||
accented := "ãããããã ãããããã ãããããã ãããããã ãããããã" // 34 runes, 64 bytes
|
||||
if got := PhraseKey(accented); got != accented {
|
||||
t.Errorf("PhraseKey(%d runes / %d bytes) = %q, want it kept", len([]rune(accented)), len(accented), got)
|
||||
}
|
||||
long := "ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã ãããããããããããã"
|
||||
if got := PhraseKey(long); got != "" {
|
||||
t.Errorf("PhraseKey(%d runes) = %q, want \"\"", len([]rune(long)), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlantCreatesOnceAndReportsIt(t *testing.T) {
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
p := Phrase{Text: "make a decision", Meaning: "why", Example: "I had to make a decision."}
|
||||
created, err := Plant(database, db.LocalUserID, p)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first plant: created=%v err=%v", created, err)
|
||||
}
|
||||
created, err = Plant(database, db.LocalUserID, p)
|
||||
if err != nil || created {
|
||||
t.Fatalf("second plant: created=%v err=%v, want false", created, err)
|
||||
}
|
||||
|
||||
// A card that isn't plantable is a silent no-op, not an error: planting hangs
|
||||
// off accepting an edit, and that accept must never fail for a flashcard.
|
||||
created, err = Plant(database, db.LocalUserID, Phrase{Text: "decision"})
|
||||
if err != nil || created {
|
||||
t.Fatalf("unplantable: created=%v err=%v", created, err)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := database.QueryRow(`SELECT count(*) FROM vocab_words WHERE user_id = ?`, db.LocalUserID).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("garden has %d cards, want 1", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user