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
89 lines
2.7 KiB
Go
89 lines
2.7 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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)
|
|
}
|
|
}
|