Code-review fixes for collocation coach + vocab garden

Correctness:
- useCheckpoint: clear the busy flag unconditionally so overlapping
  explicit passes don't strand each other's spinner; explicit actions
  now also supersede a queued auto-check and clear the stranded
  "checking" dot. Deduped runVoice/runCollocation into runExplicitPass.
- EditorCore: token-guard the auto-capture so a late capture can't
  resurrect a removed word; move toggleSaveWord side effects out of the
  setWordInfo updater (StrictMode double-fire); fix sentenceAround offset
  desync via shared exampleAt (textBetween + parentOffset, single resolve);
  optimistic saved state so the heart doesn't flash unsaved.
- vocab capture: normalize word to lower+trim (matches lexicon) so
  "Apple"/"apple" don't make duplicate cards; check rows.Err() in queryList.
- GardenPanel: Promise.allSettled so a /due failure doesn't blank the
  whole garden; scrim click during review ends the review (mirrors Esc);
  gate footer on !error; O(1) due lookup via a Set.

Features requested in review:
- Definition-only review card: add vocab_words.definition (migration
  0007) as an English fallback meaning, threaded through capture and used
  by review/garden when there's no Chinese gloss.
- Scheduler caps: maxEase 3.0 + maxInterval 365d so "easy" growth can't
  push a word out of rotation for years.

Tests: TestCaptureCaseInsensitive, TestCaptureStoresDefinitionFallback,
TestCapsBoundGrowth. go build/vet/test, tsc, vitest 51/51, vite build clean.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 16:41:28 -07:00
parent 8aa437ec82
commit 4161830da6
9 changed files with 256 additions and 98 deletions

View File

@@ -51,6 +51,28 @@ func TestAgainStepsBackGently(t *testing.T) {
}
}
// TestCapsBoundGrowth proves runaway "easy" grading can't push ease or the
// interval past their ceilings, so a word always resurfaces within a year.
func TestCapsBoundGrowth(t *testing.T) {
s := State{Ease: startEase}
for i := 0; i < 40; i++ {
s = s.next(GradeEasy)
if s.Ease > maxEase {
t.Fatalf("step %d: ease %v exceeded cap %v", i, s.Ease, maxEase)
}
if s.Interval > maxInterval {
t.Fatalf("step %d: interval %d exceeded cap %d", i, s.Interval, maxInterval)
}
}
// After enough "easy" reviews it should be pinned at the ceilings.
if s.Interval != maxInterval {
t.Fatalf("interval should saturate at %d, got %d", maxInterval, s.Interval)
}
if s.Ease != maxEase {
t.Fatalf("ease should saturate at %v, got %v", maxEase, s.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) {