Phase 12 + 13: collocation coach + vocabulary garden
Phase 12 — collocation coach: a third suggestion family for gentle
"natives usually say…" hints on non-native word pairings, reusing the
existing runPass/pendingScope/rail machinery.
- llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint)
+ collocationSystemPrompt/CollocationMessages (warm, Mandarin gloss,
defers grammar/spelling to the grammar family)
- migration 0005 rebuilds the suggestions table to extend the type CHECK
(SQLite can't ALTER a CHECK)
- collocationScope + CollocationLimit + POST /{id}/collocation
- fix: grammarScope was `type != 'voice'` and would wipe the new
collocation flags; now `type NOT IN ('voice','collocation')`
- frontend: --color-blossom, "Make it sound natural 🌸" pill,
collocating/runCollocation in useCheckpoint, StatusBar dot
Phase 13 — vocabulary garden: capture looked-up words and surface them
for gentle spaced repetition.
- new internal/vocab package: migration 0006 (vocab_words, SM-2-lite
columns, doc_id ON DELETE SET NULL, UNIQUE(user_id,word)),
scheduler.go (Leitner ladder 1/3/7/16/35 then geometric; gentle
"again", no streak-shaming), handlers (capture-upsert/list/due/
review/delete, owner-scoped, SQLite-side datetime math)
- auto-capture on word lookup (dictionary-known words only, captures
the surrounding sentence + doc_id) + 🤍/💚 toggle on WordCard
- GardenPanel: blossom grid (bloom by reps), flashcard review (sentence
blanked, flip, again/good/easy, recognition↔production), sleepy-kitten
footer; opened from a global 🌷 header button
Tests: TestCollocationPassCoexists, vocab scheduler + handlers, db CHECK
extended. go build/vet/test + tsc + vite + vitest (51/51) clean;
migration verified against a copy of the live DB; live backend smoke
walked the full vocab lifecycle + the warm-502 collocation path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -260,6 +260,71 @@ END;
|
||||
|
||||
INSERT INTO documents_fts (doc_id, title, content_text)
|
||||
SELECT id, title, content_text FROM documents;
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Collocation coach (Phase 12). Adds a third suggestion family,
|
||||
// 'collocation', for gentle "natives usually say…" hints on
|
||||
// non-native word pairings. The `type` column carries a CHECK
|
||||
// constraint and SQLite cannot ALTER one in place, so we rebuild the
|
||||
// suggestions table with the extended CHECK, copy every row across,
|
||||
// and recreate its index. Nothing references suggestions, so dropping
|
||||
// the old table is safe; the new table keeps the same ON DELETE
|
||||
// CASCADE to documents.
|
||||
name: "0005_collocation_suggestion_type",
|
||||
stmt: `
|
||||
CREATE TABLE suggestions_new (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
from_pos INTEGER NOT NULL,
|
||||
to_pos INTEGER NOT NULL,
|
||||
original TEXT NOT NULL,
|
||||
replacement TEXT NOT NULL,
|
||||
explanation TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice','collocation')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
||||
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
|
||||
|
||||
DROP TABLE suggestions;
|
||||
ALTER TABLE suggestions_new RENAME TO suggestions;
|
||||
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Vocabulary garden (Phase 13). Every word the writer looks up is
|
||||
// captured here and put on a gentle spaced-repetition schedule, turning
|
||||
// passive lookups into real vocabulary. `example` holds the sentence the
|
||||
// word appeared in (captured at lookup) for context during review;
|
||||
// `doc_id` links back to where she met the word (nulled if that doc is
|
||||
// deleted — the word stays in the garden). The SM-2-lite scheduling
|
||||
// columns (due_at/interval_days/ease/reps/lapses/last_reviewed) drive a
|
||||
// Leitner-style ladder (see internal/vocab/scheduler.go). UNIQUE on
|
||||
// (user_id, word) makes capture an idempotent upsert.
|
||||
name: "0006_vocab_garden",
|
||||
stmt: `
|
||||
CREATE TABLE vocab_words (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
word TEXT NOT NULL,
|
||||
gloss TEXT NOT NULL DEFAULT '',
|
||||
phonetic TEXT NOT NULL DEFAULT '',
|
||||
example TEXT NOT NULL DEFAULT '',
|
||||
doc_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
|
||||
due_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
interval_days INTEGER NOT NULL DEFAULT 0,
|
||||
ease REAL NOT NULL DEFAULT 2.5,
|
||||
reps INTEGER NOT NULL DEFAULT 0,
|
||||
lapses INTEGER NOT NULL DEFAULT 0,
|
||||
last_reviewed DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, word)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_vocab_due ON vocab_words(user_id, due_at);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ func TestOpenMigratesAndSeeds(t *testing.T) {
|
||||
t.Fatalf("insert voice suggestion: %v", err)
|
||||
}
|
||||
|
||||
// The collocation type (added by migration 0005's table rebuild) is permitted.
|
||||
if _, err := d.Exec(
|
||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||
VALUES ('d1', 0, 9, 'do a decision', 'make a decision', 'natives usually say…', 'collocation')`,
|
||||
); err != nil {
|
||||
t.Fatalf("insert collocation suggestion: %v", err)
|
||||
}
|
||||
|
||||
// An invalid type is rejected.
|
||||
if _, err := d.Exec(
|
||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||
|
||||
@@ -88,18 +88,19 @@ type Suggestion struct {
|
||||
Original string `json:"original"`
|
||||
Replacement string `json:"replacement"`
|
||||
Explanation string `json:"explanation"`
|
||||
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice
|
||||
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice | collocation
|
||||
Status string `json:"status"` // pending | accepted | rejected
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Suggestion type and status values, mirrored from the schema CHECK constraints.
|
||||
const (
|
||||
SuggestionTypeGrammar = "grammar"
|
||||
SuggestionTypePhrasing = "phrasing"
|
||||
SuggestionTypeIdiom = "idiom"
|
||||
SuggestionTypeClarity = "clarity"
|
||||
SuggestionTypeVoice = "voice"
|
||||
SuggestionTypeGrammar = "grammar"
|
||||
SuggestionTypePhrasing = "phrasing"
|
||||
SuggestionTypeIdiom = "idiom"
|
||||
SuggestionTypeClarity = "clarity"
|
||||
SuggestionTypeVoice = "voice"
|
||||
SuggestionTypeCollocation = "collocation"
|
||||
|
||||
SuggestionStatusPending = "pending"
|
||||
SuggestionStatusAccepted = "accepted"
|
||||
|
||||
35
internal/llm/collocation.go
Normal file
35
internal/llm/collocation.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CollocationInterval is the minimum gap between collocation passes for one
|
||||
// document. Like the voice pass it runs on an explicit user action ("Make it
|
||||
// sound natural") rather than a typing cadence, so this floor only guards the
|
||||
// inference endpoint against the button being mashed.
|
||||
const CollocationInterval = 25 * time.Second
|
||||
|
||||
// RunCollocation sends the WHOLE document for a collocation pass — gentle
|
||||
// "natives usually say…" hints on non-native word pairings — and parses the JSON
|
||||
// result with the checkpoint's tolerant parser. It is deliberately NOT
|
||||
// TruncateDoc'd: like the voice pass it reads the whole document so the hints
|
||||
// reflect the full piece. Each flag carries a native replacement to apply.
|
||||
//
|
||||
// The tone argument is accepted for a uniform pass signature and passed through
|
||||
// to the prompt so a hint can prefer a register-appropriate pairing.
|
||||
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
||||
raw, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: CollocationMessages(contentText, tone),
|
||||
MaxTokens: 2048,
|
||||
Temperature: 0.3,
|
||||
RepetitionPenalty: 1.15,
|
||||
TopP: 0.9,
|
||||
Stop: []string{"```", "\n\n\n\n"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseCheckpoint(raw)
|
||||
}
|
||||
@@ -92,6 +92,54 @@ func VoiceMessages(contentText string) []Message {
|
||||
}
|
||||
}
|
||||
|
||||
// collocationSystemPrompt drives the collocation coach — the single most
|
||||
// valuable polish for an ESL writer. It flags word PAIRINGS that are not wrong,
|
||||
// just non-native ("do a decision" → "make a decision", "strong rain" → "heavy
|
||||
// rain"), and explicitly DEFERS real grammar/spelling errors to the grammar
|
||||
// checkpoint so the two families don't overlap. Every explanation is framed as a
|
||||
// warm "natives usually say…" note with a short Mandarin gloss — never
|
||||
// "error/wrong" — because these are stylistic, not mistakes. It is a distinct
|
||||
// pass from the grammar checkpoint (do not bundle them). `replacement` carries
|
||||
// the natural pairing the writer can accept in one tap.
|
||||
const collocationSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
|
||||
`You are reviewing a COMPLETE document for COLLOCATIONS only — the natural word pairings native speakers use.
|
||||
|
||||
A collocation is a pair or short group of words that native speakers habitually say together. ESL writers ` +
|
||||
`often choose words that are grammatically correct but sound non-native: "do a decision" instead of "make a decision", ` +
|
||||
`"strong rain" instead of "heavy rain", "say a joke" instead of "tell a joke". These are NOT grammar mistakes — they ` +
|
||||
`are just not what a native speaker would naturally say.
|
||||
|
||||
Identify up to 5 such non-native word pairings. For each, give the natural pairing a native speaker would use. ` +
|
||||
`Be gentle and specific. Do NOT flag grammar errors, spelling mistakes, or unclear sentences — those are handled ` +
|
||||
`elsewhere. Only flag word pairings that are correct but sound non-native.%s
|
||||
|
||||
Phrase every explanation warmly as "Natives usually say…" and include a brief Simplified Chinese gloss in parentheses. ` +
|
||||
`Never use the words "error", "wrong", or "mistake" — these are friendly polish, not corrections.
|
||||
|
||||
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"original": "exact word pairing from the document",
|
||||
"replacement": "the natural native pairing",
|
||||
"explanation": "friendly note, e.g. 'Natives usually say \"make a decision\" rather than \"do a decision\" (native usage / 地道说法).'",
|
||||
"type": "collocation"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
If every pairing already sounds natural, return: {"suggestions": []}`
|
||||
|
||||
// CollocationMessages builds the message array for a collocation pass over the
|
||||
// WHOLE document (no truncation), gently steered toward the document's tone so a
|
||||
// hint can prefer a register-appropriate pairing.
|
||||
func CollocationMessages(contentText, tone string) []Message {
|
||||
return []Message{
|
||||
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone))},
|
||||
{Role: "user", Content: contentText},
|
||||
}
|
||||
}
|
||||
|
||||
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
|
||||
// is interpolated in; the user's own messages are appended after this system
|
||||
// turn by the caller.
|
||||
|
||||
@@ -23,19 +23,22 @@ import (
|
||||
// grammar checkpoint and the voice pass each get their own per-document rate
|
||||
// limiter — they are independent passes with different cadences.
|
||||
type Handler struct {
|
||||
DB *db.DB
|
||||
Client llm.LLMClient
|
||||
Limit *llm.RateLimiter // grammar checkpoint floor
|
||||
VoiceLimit *llm.RateLimiter // voice-consistency floor
|
||||
DB *db.DB
|
||||
Client llm.LLMClient
|
||||
Limit *llm.RateLimiter // grammar checkpoint floor
|
||||
VoiceLimit *llm.RateLimiter // voice-consistency floor
|
||||
CollocationLimit *llm.RateLimiter // collocation-coach floor
|
||||
}
|
||||
|
||||
// New constructs a Handler with per-document checkpoint and voice rate limiters.
|
||||
// New constructs a Handler with per-document checkpoint, voice, and collocation
|
||||
// rate limiters.
|
||||
func New(database *db.DB, client llm.LLMClient) *Handler {
|
||||
return &Handler{
|
||||
DB: database,
|
||||
Client: client,
|
||||
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
|
||||
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
|
||||
DB: database,
|
||||
Client: client,
|
||||
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
|
||||
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
|
||||
CollocationLimit: llm.NewRateLimiter(llm.CollocationInterval),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +47,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
|
||||
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||
r.Post("/{id}/check", h.check)
|
||||
r.Post("/{id}/voice", h.voice)
|
||||
r.Post("/{id}/collocation", h.collocation)
|
||||
r.Post("/{id}/rewrite", h.rewrite)
|
||||
r.Get("/{id}/suggestions", h.listForDoc)
|
||||
}
|
||||
@@ -70,6 +74,13 @@ func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
||||
h.runPass(w, r, h.VoiceLimit, llm.RunVoice, voiceScope)
|
||||
}
|
||||
|
||||
// collocation runs the collocation coach over the whole document, flagging
|
||||
// non-native word pairings. Explicit-action pass; replaces only the pending
|
||||
// collocation flags.
|
||||
func (h *Handler) collocation(w http.ResponseWriter, r *http.Request) {
|
||||
h.runPass(w, r, h.CollocationLimit, llm.RunCollocation, collocationScope)
|
||||
}
|
||||
|
||||
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
||||
// given the document text and the document's tone it returns the model's raw
|
||||
// suggestions. The voice pass ignores tone (see llm.RunVoice).
|
||||
@@ -151,10 +162,14 @@ type pendingScope struct {
|
||||
}
|
||||
|
||||
var (
|
||||
// grammarScope owns the grammar/phrasing/idiom/clarity flags (everything but voice).
|
||||
grammarScope = pendingScope{deleteWhere: "type != 'voice'", forceType: ""}
|
||||
// grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but
|
||||
// the explicit-action families (voice, collocation), which run on their own
|
||||
// cadence and must survive a grammar checkpoint.
|
||||
grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation')", forceType: ""}
|
||||
// voiceScope owns the voice flags only.
|
||||
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
|
||||
// collocationScope owns the collocation flags only.
|
||||
collocationScope = pendingScope{deleteWhere: "type = 'collocation'", forceType: db.SuggestionTypeCollocation}
|
||||
)
|
||||
|
||||
// replacePending swaps a document's pending suggestions within one family for a
|
||||
@@ -320,7 +335,7 @@ func locate(contentText, original string) (int, int) {
|
||||
// defaulting unknown values to grammar so a stray label never trips the CHECK.
|
||||
func normalizeType(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity:
|
||||
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity, db.SuggestionTypeCollocation:
|
||||
return strings.ToLower(strings.TrimSpace(t))
|
||||
default:
|
||||
return db.SuggestionTypeGrammar
|
||||
|
||||
@@ -227,6 +227,65 @@ func TestVoicePassCoexists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollocationPassCoexists proves the collocation coach is a third
|
||||
// independent family: it never wipes grammar or voice pending flags, a grammar
|
||||
// checkpoint never wipes its flags, and each endpoint returns the unified set.
|
||||
func TestCollocationPassCoexists(t *testing.T) {
|
||||
client := &switchClient{}
|
||||
srv, docID, h := newTestServer(t, client)
|
||||
// Zero the floors so the test can re-run passes without waiting them out.
|
||||
h.Limit = llm.NewRateLimiter(0)
|
||||
h.VoiceLimit = llm.NewRateLimiter(0)
|
||||
h.CollocationLimit = llm.NewRateLimiter(0)
|
||||
|
||||
// Grammar + voice first, so all three families are exercised.
|
||||
client.response = `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}]}`
|
||||
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
client.response = `{"suggestions":[{"original":"two apple","replacement":null,"explanation":"sounds formal","type":"voice"}]}`
|
||||
if rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("voice: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// Collocation pass → a third flag (with a replacement). It must keep the
|
||||
// grammar and voice flags; the response is the unified set of all three.
|
||||
client.response = `{"suggestions":[{"original":"two apple","replacement":"two apples","explanation":"Natives usually say…","type":"collocation"}]}`
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/collocation", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("collocation: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var got []db.Suggestion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
byType := map[string]bool{}
|
||||
for _, s := range got {
|
||||
byType[s.Type] = true
|
||||
}
|
||||
if !byType[db.SuggestionTypeGrammar] || !byType[db.SuggestionTypeVoice] || !byType[db.SuggestionTypeCollocation] {
|
||||
t.Fatalf("collocation response should carry all three families, got %+v", got)
|
||||
}
|
||||
|
||||
// A grammar checkpoint must NOT wipe the voice or collocation flags.
|
||||
client.response = `{"suggestions":[]}`
|
||||
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
byType = map[string]bool{}
|
||||
for _, s := range got {
|
||||
byType[s.Type] = true
|
||||
}
|
||||
if byType[db.SuggestionTypeGrammar] {
|
||||
t.Fatalf("grammar flag should have cleared, got %+v", got)
|
||||
}
|
||||
if !byType[db.SuggestionTypeVoice] || !byType[db.SuggestionTypeCollocation] {
|
||||
t.Fatalf("grammar checkpoint wiped a sibling family, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// switchClient returns a response that can be swapped between calls.
|
||||
type switchClient struct {
|
||||
response string
|
||||
|
||||
245
internal/vocab/handlers.go
Normal file
245
internal/vocab/handlers.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package vocab
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
// Word is one entry in the vocabulary garden: the looked-up word with its gloss,
|
||||
// phonetic, and the sentence it was met in, plus its spaced-repetition state.
|
||||
type Word struct {
|
||||
ID string `json:"id"`
|
||||
Word string `json:"word"`
|
||||
Gloss string `json:"gloss"`
|
||||
Phonetic string `json:"phonetic"`
|
||||
Example string `json:"example"`
|
||||
DocID *string `json:"doc_id"`
|
||||
DueAt time.Time `json:"due_at"`
|
||||
IntervalDays int `json:"interval_days"`
|
||||
Ease float64 `json:"ease"`
|
||||
Reps int `json:"reps"`
|
||||
Lapses int `json:"lapses"`
|
||||
LastReviewed *time.Time `json:"last_reviewed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Handler owns the vocabulary-garden routes. Everything is scoped to the local
|
||||
// user while auth is deferred.
|
||||
type Handler struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
func New(database *db.DB) *Handler { return &Handler{DB: database} }
|
||||
|
||||
// Routes mounts the garden endpoints under /api/vocab.
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", h.list) // the whole garden
|
||||
r.Get("/due", h.due) // only the cards due for review now
|
||||
r.Post("/", h.capture) // record/upsert a looked-up word
|
||||
r.Post("/{id}/review", h.review)
|
||||
r.Delete("/{id}", h.remove)
|
||||
return r
|
||||
}
|
||||
|
||||
const vocabColumns = `id, word, gloss, phonetic, example, doc_id,
|
||||
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
|
||||
|
||||
func scanWord(s interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Word, error) {
|
||||
var w Word
|
||||
err := s.Scan(
|
||||
&w.ID, &w.Word, &w.Gloss, &w.Phonetic, &w.Example, &w.DocID,
|
||||
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
|
||||
)
|
||||
return w, err
|
||||
}
|
||||
|
||||
// list returns the full garden, newest blossoms first.
|
||||
func (h *Handler) list(w http.ResponseWriter, _ *http.Request) {
|
||||
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
|
||||
WHERE user_id = ? ORDER BY created_at DESC`, db.LocalUserID)
|
||||
}
|
||||
|
||||
// due returns only the cards whose review time has arrived, soonest first.
|
||||
func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
|
||||
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
|
||||
WHERE user_id = ? AND due_at <= datetime('now') ORDER BY due_at ASC`, db.LocalUserID)
|
||||
}
|
||||
|
||||
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
|
||||
rows, err := h.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Word{}
|
||||
for rows.Next() {
|
||||
word, err := scanWord(rows)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
out = append(out, word)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type captureRequest struct {
|
||||
Word string `json:"word"`
|
||||
Gloss string `json:"gloss"`
|
||||
Phonetic string `json:"phonetic"`
|
||||
Example string `json:"example"`
|
||||
DocID *string `json:"doc_id"`
|
||||
}
|
||||
|
||||
// capture records a looked-up word. It's an idempotent upsert keyed on the word:
|
||||
// a new word lands due tomorrow (interval 1 day); an existing word keeps its
|
||||
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
|
||||
// recent context wins. Looking words up IS the data source — no extra effort.
|
||||
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
||||
var req captureRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
word := strings.TrimSpace(req.Word)
|
||||
if word == "" {
|
||||
errorJSON(w, http.StatusBadRequest, "word is required")
|
||||
return
|
||||
}
|
||||
|
||||
// New rows start due tomorrow; ON CONFLICT refreshes context but leaves the
|
||||
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
|
||||
// resets its progress.
|
||||
_, err := h.DB.Exec(
|
||||
`INSERT INTO vocab_words (user_id, word, gloss, phonetic, example, doc_id, due_at, interval_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
|
||||
ON CONFLICT(user_id, word) DO UPDATE SET
|
||||
gloss = excluded.gloss,
|
||||
phonetic = excluded.phonetic,
|
||||
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
|
||||
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
|
||||
db.LocalUserID, word, req.Gloss, req.Phonetic, req.Example, req.DocID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.fetch(word)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// fetch loads one word row by its (user, word) key.
|
||||
func (h *Handler) fetch(word string) (Word, error) {
|
||||
return scanWord(h.DB.QueryRow(
|
||||
`SELECT `+vocabColumns+` FROM vocab_words WHERE user_id = ? AND word = ?`,
|
||||
db.LocalUserID, word,
|
||||
))
|
||||
}
|
||||
|
||||
type reviewRequest struct {
|
||||
Grade Grade `json:"grade"`
|
||||
}
|
||||
|
||||
// review grades one card and reschedules it. The grade drives the SM-2-lite
|
||||
// scheduler; the new interval is applied as `due_at = now + interval days`.
|
||||
func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req reviewRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy {
|
||||
errorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy")
|
||||
return
|
||||
}
|
||||
|
||||
var cur State
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "word not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
nxt := cur.next(req.Grade)
|
||||
// `datetime('now', '+N days')` keeps the stored value in SQLite's canonical
|
||||
// text format, matching CURRENT_TIMESTAMP and the due query's comparison.
|
||||
offset := "+" + strconv.Itoa(nxt.Interval) + " days"
|
||||
if _, err := h.DB.Exec(
|
||||
`UPDATE vocab_words SET
|
||||
reps = ?, interval_days = ?, ease = ?, lapses = ?,
|
||||
last_reviewed = datetime('now'), due_at = datetime('now', ?)
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, db.LocalUserID,
|
||||
); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := scanWord(h.DB.QueryRow(
|
||||
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, db.LocalUserID,
|
||||
))
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// remove deletes a word from the garden (e.g. the writer already knows it).
|
||||
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := h.DB.Exec(
|
||||
`DELETE FROM vocab_words WHERE id = ? AND user_id = ?`,
|
||||
chi.URLParam(r, "id"), db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
errorJSON(w, http.StatusNotFound, "word not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- response helpers -------------------------------------------------------
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func serverError(w http.ResponseWriter, err error) {
|
||||
errorJSON(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
170
internal/vocab/handlers_test.go
Normal file
170
internal/vocab/handlers_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package vocab
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) (http.Handler, *db.DB) {
|
||||
t.Helper()
|
||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/vocab", New(database).Routes())
|
||||
return r, database
|
||||
}
|
||||
|
||||
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var r *http.Request
|
||||
if body != "" {
|
||||
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
} else {
|
||||
r = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, r)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestCaptureUpsertAndReview walks the garden lifecycle: capture a word (lands
|
||||
// in the future, not yet due), re-capture refreshes context without resetting
|
||||
// schedule, a forced-due word shows up in /due, a review reschedules it, and
|
||||
// delete removes it.
|
||||
func TestCaptureUpsertAndReview(t *testing.T) {
|
||||
srv, database := newTestServer(t)
|
||||
|
||||
// Capture a new word → 201, due tomorrow (interval 1), not in /due yet.
|
||||
rec := do(t, srv, http.MethodPost, "/vocab",
|
||||
`{"word":"serendipity","gloss":"机缘巧合","phonetic":"/ˌserənˈdɪpəti/","example":"What a serendipity."}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var w Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &w)
|
||||
if w.ID == "" || w.Word != "serendipity" || w.Gloss != "机缘巧合" {
|
||||
t.Fatalf("captured word wrong: %+v", w)
|
||||
}
|
||||
if w.IntervalDays != 1 || w.Reps != 0 {
|
||||
t.Fatalf("new word should start at interval 1, reps 0: %+v", w)
|
||||
}
|
||||
|
||||
// Not due yet (due tomorrow).
|
||||
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||||
var due []Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||||
if len(due) != 0 {
|
||||
t.Fatalf("freshly-captured word should not be due yet, got %d", len(due))
|
||||
}
|
||||
|
||||
// Re-capture with a new gloss → still one row (idempotent upsert), refreshed.
|
||||
rec = do(t, srv, http.MethodPost, "/vocab",
|
||||
`{"word":"serendipity","gloss":"意外的好运","phonetic":"/x/","example":""}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("re-capture: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/vocab", "")
|
||||
var all []Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("re-capture should not add a row, got %d", len(all))
|
||||
}
|
||||
if all[0].Gloss != "意外的好运" {
|
||||
t.Fatalf("gloss should refresh on re-capture, got %q", all[0].Gloss)
|
||||
}
|
||||
if all[0].Example != "What a serendipity." {
|
||||
t.Fatalf("empty example should not clobber the prior one, got %q", all[0].Example)
|
||||
}
|
||||
|
||||
// Force it due now, then it appears in /due.
|
||||
if _, err := database.Exec(`UPDATE vocab_words SET due_at = datetime('now','-1 hour')`); err != nil {
|
||||
t.Fatalf("force due: %v", err)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("forced-due word should be in /due, got %d", len(due))
|
||||
}
|
||||
|
||||
// Review "good" → reschedules forward (interval 3 = second ladder rung, since
|
||||
// reps was 0 and a capture set interval 1 but reps 0; first good → rung 1 = 1).
|
||||
rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"good"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("review: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var reviewed Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &reviewed)
|
||||
if reviewed.Reps != 1 || reviewed.IntervalDays != 1 {
|
||||
t.Fatalf("first good review should be reps 1, interval 1: %+v", reviewed)
|
||||
}
|
||||
if reviewed.LastReviewed == nil {
|
||||
t.Fatalf("review should stamp last_reviewed")
|
||||
}
|
||||
|
||||
// After a forward review it's no longer due.
|
||||
rec = do(t, srv, http.MethodGet, "/vocab/due", "")
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &due)
|
||||
if len(due) != 0 {
|
||||
t.Fatalf("reviewed word should leave /due, got %d", len(due))
|
||||
}
|
||||
|
||||
// Bad grade → 400.
|
||||
if rec = do(t, srv, http.MethodPost, "/vocab/"+w.ID+"/review", `{"grade":"nope"}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad grade: want 400, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Delete → 204, garden empty.
|
||||
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: code=%d", rec.Code)
|
||||
}
|
||||
if rec = do(t, srv, http.MethodDelete, "/vocab/"+w.ID, ""); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("re-delete: want 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureValidation rejects an empty word.
|
||||
func TestCaptureValidation(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
if rec := do(t, srv, http.MethodPost, "/vocab", `{"word":" "}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty word: want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocLinkSurvivesDocDelete proves the ON DELETE SET NULL keeps a word in the
|
||||
// garden when its source document is removed.
|
||||
func TestDocLinkSurvivesDocDelete(t *testing.T) {
|
||||
srv, database := newTestServer(t)
|
||||
var docID string
|
||||
if err := database.QueryRow(
|
||||
`INSERT INTO documents (user_id, content_text) VALUES (?, 'hi') RETURNING id`, db.LocalUserID,
|
||||
).Scan(&docID); err != nil {
|
||||
t.Fatalf("seed doc: %v", err)
|
||||
}
|
||||
rec := do(t, srv, http.MethodPost, "/vocab", `{"word":"ephemeral","gloss":"短暂的","doc_id":"`+docID+`"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("capture: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
if _, err := database.Exec(`DELETE FROM documents WHERE id = ?`, docID); err != nil {
|
||||
t.Fatalf("delete doc: %v", err)
|
||||
}
|
||||
rec = do(t, srv, http.MethodGet, "/vocab", "")
|
||||
var all []Word
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &all)
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("word should survive doc deletion, got %d", len(all))
|
||||
}
|
||||
if all[0].DocID != nil {
|
||||
t.Fatalf("doc_id should be nulled after doc delete, got %v", *all[0].DocID)
|
||||
}
|
||||
}
|
||||
97
internal/vocab/scheduler.go
Normal file
97
internal/vocab/scheduler.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Package vocab implements the vocabulary garden: it captures words the writer
|
||||
// looks up and schedules them for gentle spaced-repetition review. The scheduler
|
||||
// is a deliberately forgiving SM-2-lite / Leitner hybrid — no streaks to break,
|
||||
// no harsh resets beyond a single step back — because this is a confidence
|
||||
// builder, not a drill sergeant.
|
||||
package vocab
|
||||
|
||||
import "math"
|
||||
|
||||
// Grade is the writer's self-assessment after seeing a flashcard's answer.
|
||||
type Grade string
|
||||
|
||||
const (
|
||||
GradeAgain Grade = "again" // didn't recall it — show again soon
|
||||
GradeGood Grade = "good" // recalled it — advance one rung
|
||||
GradeEasy Grade = "easy" // knew it instantly — advance a little further
|
||||
)
|
||||
|
||||
// ladder is the Leitner interval ladder in days for the first several successful
|
||||
// reviews: 1 → 3 → 7 → 16 → 35. Beyond the ladder, intervals grow by the card's
|
||||
// ease factor so well-known words drift far into the future.
|
||||
var ladder = []int{1, 3, 7, 16, 35}
|
||||
|
||||
const (
|
||||
minEase = 1.3
|
||||
startEase = 2.5
|
||||
easyBonus = 1.3 // multiplier applied on top of a "good" step for "easy"
|
||||
easeAgainDelta = -0.20 // ease nudged down on a lapse (still floored at minEase)
|
||||
easeEasyDelta = 0.15 // ease nudged up when a card feels easy
|
||||
)
|
||||
|
||||
// State is the spaced-repetition state of one card. It mirrors the scheduling
|
||||
// columns on vocab_words so a review is "load State → next(grade) → persist".
|
||||
type State struct {
|
||||
Reps int
|
||||
Interval int // days until the next review
|
||||
Ease float64
|
||||
Lapses int
|
||||
}
|
||||
|
||||
// next returns the card's state after a review with the given grade. It never
|
||||
// mutates the receiver. "again" steps the card back to a 1-day interval and
|
||||
// counts a lapse (but only nudges ease down, never wipes progress harshly);
|
||||
// "good" advances one rung of the ladder; "easy" advances a rung and a bit more.
|
||||
func (s State) next(grade Grade) State {
|
||||
ease := s.Ease
|
||||
if ease == 0 {
|
||||
ease = startEase
|
||||
}
|
||||
|
||||
switch grade {
|
||||
case GradeAgain:
|
||||
return State{
|
||||
Reps: 0,
|
||||
Interval: 1,
|
||||
Ease: clampEase(ease + easeAgainDelta),
|
||||
Lapses: s.Lapses + 1,
|
||||
}
|
||||
case GradeEasy:
|
||||
ease = clampEase(ease + easeEasyDelta)
|
||||
reps := s.Reps + 1
|
||||
return State{
|
||||
Reps: reps,
|
||||
Interval: int(math.Round(float64(goodInterval(reps, s.Interval, ease)) * easyBonus)),
|
||||
Ease: ease,
|
||||
Lapses: s.Lapses,
|
||||
}
|
||||
default: // GradeGood
|
||||
reps := s.Reps + 1
|
||||
return State{
|
||||
Reps: reps,
|
||||
Interval: goodInterval(reps, s.Interval, ease),
|
||||
Ease: ease,
|
||||
Lapses: s.Lapses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// goodInterval returns the day-interval for a successful review: the explicit
|
||||
// ladder while it lasts, then geometric growth by ease once past it.
|
||||
func goodInterval(reps, prevInterval int, ease float64) int {
|
||||
if reps >= 1 && reps <= len(ladder) {
|
||||
return ladder[reps-1]
|
||||
}
|
||||
prev := prevInterval
|
||||
if prev < ladder[len(ladder)-1] {
|
||||
prev = ladder[len(ladder)-1]
|
||||
}
|
||||
return int(math.Round(float64(prev) * ease))
|
||||
}
|
||||
|
||||
func clampEase(e float64) float64 {
|
||||
if e < minEase {
|
||||
return minEase
|
||||
}
|
||||
return e
|
||||
}
|
||||
66
internal/vocab/scheduler_test.go
Normal file
66
internal/vocab/scheduler_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package vocab
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLadderProgression walks a card up the Leitner ladder on repeated "good"
|
||||
// reviews: 1 → 3 → 7 → 16 → 35 days, then geometric growth by ease.
|
||||
func TestLadderProgression(t *testing.T) {
|
||||
s := State{Ease: startEase}
|
||||
want := []int{1, 3, 7, 16, 35}
|
||||
for i, w := range want {
|
||||
s = s.next(GradeGood)
|
||||
if s.Interval != w {
|
||||
t.Fatalf("rung %d: interval=%d, want %d", i, s.Interval, w)
|
||||
}
|
||||
if s.Reps != i+1 {
|
||||
t.Fatalf("rung %d: reps=%d, want %d", i, s.Reps, i+1)
|
||||
}
|
||||
}
|
||||
// Past the ladder it grows by ease (35 * 2.5 = 87.5 → 88).
|
||||
s = s.next(GradeGood)
|
||||
if s.Interval != 88 {
|
||||
t.Fatalf("post-ladder interval=%d, want 88", s.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgainStepsBackGently proves a lapse resets to a 1-day interval and counts
|
||||
// a lapse, but only nudges ease down (never below the floor) — no harsh wipe.
|
||||
func TestAgainStepsBackGently(t *testing.T) {
|
||||
s := State{Reps: 4, Interval: 35, Ease: startEase}
|
||||
s = s.next(GradeAgain)
|
||||
if s.Interval != 1 {
|
||||
t.Fatalf("again interval=%d, want 1", s.Interval)
|
||||
}
|
||||
if s.Reps != 0 {
|
||||
t.Fatalf("again reps=%d, want 0", s.Reps)
|
||||
}
|
||||
if s.Lapses != 1 {
|
||||
t.Fatalf("again lapses=%d, want 1", s.Lapses)
|
||||
}
|
||||
if s.Ease != startEase+easeAgainDelta {
|
||||
t.Fatalf("again ease=%v, want %v", s.Ease, startEase+easeAgainDelta)
|
||||
}
|
||||
|
||||
// Ease never drops below the floor no matter how many lapses.
|
||||
low := State{Ease: minEase}
|
||||
for i := 0; i < 5; i++ {
|
||||
low = low.next(GradeAgain)
|
||||
}
|
||||
if low.Ease < minEase {
|
||||
t.Fatalf("ease fell below floor: %v", low.Ease)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEasyAdvancesFurther proves "easy" both bumps ease and lands a longer
|
||||
// interval than a plain "good" at the same rung.
|
||||
func TestEasyAdvancesFurther(t *testing.T) {
|
||||
base := State{Reps: 2, Interval: 7, Ease: startEase}
|
||||
good := base.next(GradeGood)
|
||||
easy := base.next(GradeEasy)
|
||||
if easy.Interval <= good.Interval {
|
||||
t.Fatalf("easy interval %d should exceed good interval %d", easy.Interval, good.Interval)
|
||||
}
|
||||
if easy.Ease <= startEase {
|
||||
t.Fatalf("easy should raise ease, got %v", easy.Ease)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user