Files
beads/cmd/bd/tips_test.go
Steve Yegge 5f310bc7c2 Add tip system infrastructure (bd-d4i)
Implements a smart contextual hint system that shows helpful messages
to users after successful commands. Tips are filtered by conditions,
priority, frequency limits, and probability rolls to provide useful
information without being annoying.

Core Features:
- Tip struct with condition, message, frequency, priority, probability
- selectNextTip() filters eligible tips and applies probability
- Metadata storage tracks when tips were last shown
- Respects --json and --quiet flags
- Deterministic testing via BEADS_TIP_SEED env var

Integration Points:
- bd list: Shows tip after listing issues
- bd ready: Shows tip after showing ready work (or no work)
- bd create: Shows tip after creating issue
- bd show: Shows tip after showing issue details

Testing:
- Unit tests for tip selection logic
- Tests for frequency limits and probability
- Tests for metadata tracking
- Example tip definitions for documentation

Next Steps:
- bd-81a: Add programmatic tip injection API
- bd-tne: Add Claude setup tip with dynamic priority

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 10:56:14 -08:00

275 lines
6.2 KiB
Go

package main
import (
"context"
"os"
"sync"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage/memory"
)
func TestTipSelection(t *testing.T) {
// Set deterministic seed for testing
os.Setenv("BEADS_TIP_SEED", "12345")
defer os.Unsetenv("BEADS_TIP_SEED")
// Reset RNG
tipRandOnce = sync.Once{}
initTipRand()
// Reset tip registry for testing
tipsMutex.Lock()
tips = []Tip{}
tipsMutex.Unlock()
store := memory.New("")
// Test 1: No tips registered
tip := selectNextTip(store)
if tip != nil {
t.Errorf("Expected nil with no tips registered, got %v", tip)
}
// Test 2: Single tip with condition = true
tipsMutex.Lock()
tips = append(tips, Tip{
ID: "test_tip_1",
Condition: func() bool { return true },
Message: "Test tip 1",
Frequency: 1 * time.Hour,
Priority: 100,
Probability: 1.0, // Always show
})
tipsMutex.Unlock()
tip = selectNextTip(store)
if tip == nil {
t.Fatal("Expected tip to be selected")
}
if tip.ID != "test_tip_1" {
t.Errorf("Expected tip ID 'test_tip_1', got %q", tip.ID)
}
// Test 3: Frequency limit - should not show again immediately
recordTipShown(store, "test_tip_1")
tip = selectNextTip(store)
if tip != nil {
t.Errorf("Expected nil due to frequency limit, got %v", tip)
}
// Test 4: Multiple tips - priority order
tipsMutex.Lock()
tips = []Tip{
{
ID: "low_priority",
Condition: func() bool { return true },
Message: "Low priority tip",
Frequency: 1 * time.Hour,
Priority: 10,
Probability: 1.0,
},
{
ID: "high_priority",
Condition: func() bool { return true },
Message: "High priority tip",
Frequency: 1 * time.Hour,
Priority: 100,
Probability: 1.0,
},
}
tipsMutex.Unlock()
tip = selectNextTip(store)
if tip == nil {
t.Fatal("Expected tip to be selected")
}
if tip.ID != "high_priority" {
t.Errorf("Expected high_priority tip to be selected first, got %q", tip.ID)
}
// Test 5: Condition = false
tipsMutex.Lock()
tips = []Tip{
{
ID: "never_show",
Condition: func() bool { return false },
Message: "Never shown",
Frequency: 1 * time.Hour,
Priority: 100,
Probability: 1.0,
},
}
tipsMutex.Unlock()
tip = selectNextTip(store)
if tip != nil {
t.Errorf("Expected nil due to condition=false, got %v", tip)
}
}
func TestTipProbability(t *testing.T) {
// Set deterministic seed
os.Setenv("BEADS_TIP_SEED", "99999")
defer os.Unsetenv("BEADS_TIP_SEED")
// Reset RNG by creating a new Once
tipRandOnce = sync.Once{}
initTipRand()
tipsMutex.Lock()
tips = []Tip{
{
ID: "rare_tip",
Condition: func() bool { return true },
Message: "Rare tip",
Frequency: 1 * time.Hour,
Priority: 100,
Probability: 0.01, // 1% chance
},
}
tipsMutex.Unlock()
store := memory.New("")
// Run selection multiple times
shownCount := 0
for i := 0; i < 100; i++ {
// Clear last shown timestamp to make tip eligible
_ = store.SetMetadata(context.Background(), "tip_rare_tip_last_shown", "")
tip := selectNextTip(store)
if tip != nil {
shownCount++
}
}
// With 1% probability, we expect ~1 show out of 100
// Allow some variance (0-10 is reasonable for low probability)
if shownCount > 10 {
t.Errorf("Expected ~1 tip shown with 1%% probability, got %d", shownCount)
}
}
func TestGetLastShown(t *testing.T) {
store := memory.New("")
// Test 1: Never shown
lastShown := getLastShown(store, "never_shown")
if !lastShown.IsZero() {
t.Errorf("Expected zero time for never shown tip, got %v", lastShown)
}
// Test 2: Recently shown
now := time.Now()
_ = store.SetMetadata(context.Background(), "tip_test_last_shown", now.Format(time.RFC3339))
lastShown = getLastShown(store, "test")
if lastShown.IsZero() {
t.Error("Expected non-zero time for shown tip")
}
// Should be within 1 second (accounting for rounding)
diff := now.Sub(lastShown)
if diff < 0 {
diff = -diff
}
if diff > time.Second {
t.Errorf("Expected last shown time to be close to now, got diff %v", diff)
}
}
func TestRecordTipShown(t *testing.T) {
store := memory.New("")
recordTipShown(store, "test_tip")
// Verify it was recorded
lastShown := getLastShown(store, "test_tip")
if lastShown.IsZero() {
t.Error("Expected tip to be recorded as shown")
}
// Should be very recent
if time.Since(lastShown) > time.Second {
t.Errorf("Expected recent timestamp, got %v", lastShown)
}
}
func TestMaybeShowTip_RespectsFlags(t *testing.T) {
// Set deterministic seed
os.Setenv("BEADS_TIP_SEED", "54321")
defer os.Unsetenv("BEADS_TIP_SEED")
tipsMutex.Lock()
tips = []Tip{
{
ID: "always_show",
Condition: func() bool { return true },
Message: "Always show tip",
Frequency: 1 * time.Hour,
Priority: 100,
Probability: 1.0,
},
}
tipsMutex.Unlock()
store := memory.New("")
// Test 1: Should not show in JSON mode
jsonOutput = true
maybeShowTip(store) // Should not panic or show output
jsonOutput = false
// Test 2: Should not show in quiet mode
quietFlag = true
maybeShowTip(store) // Should not panic or show output
quietFlag = false
// Test 3: Should show in normal mode (no assertions, just testing it doesn't panic)
maybeShowTip(store)
}
func TestTipFrequency(t *testing.T) {
store := memory.New("")
tipsMutex.Lock()
tips = []Tip{
{
ID: "frequent_tip",
Condition: func() bool { return true },
Message: "Frequent tip",
Frequency: 5 * time.Second,
Priority: 100,
Probability: 1.0,
},
}
tipsMutex.Unlock()
// First selection should work
tip := selectNextTip(store)
if tip == nil {
t.Fatal("Expected tip to be selected")
}
// Record it as shown
recordTipShown(store, tip.ID)
// Should not show again immediately (within frequency window)
tip = selectNextTip(store)
if tip != nil {
t.Errorf("Expected nil due to frequency limit, got %v", tip)
}
// Manually set last shown to past (simulate time passing)
past := time.Now().Add(-10 * time.Second)
_ = store.SetMetadata(context.Background(), "tip_frequent_tip_last_shown", past.Format(time.RFC3339))
// Should show again now
tip = selectNextTip(store)
if tip == nil {
t.Error("Expected tip to be selected after frequency window passed")
}
}