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>
275 lines
6.2 KiB
Go
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")
|
|
}
|
|
}
|