From 5f310bc7c24c53b0ce2338b60c9bbdffb3725a37 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 24 Nov 2025 10:56:14 -0800 Subject: [PATCH] Add tip system infrastructure (bd-d4i) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .beads/beads.jsonl | 2 +- cmd/bd/create.go | 3 + cmd/bd/list.go | 3 + cmd/bd/ready.go | 5 + cmd/bd/show.go | 3 + cmd/bd/tips.go | 154 ++++++++++++++++++++ cmd/bd/tips_example_test.go | 99 +++++++++++++ cmd/bd/tips_test.go | 274 ++++++++++++++++++++++++++++++++++++ 8 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 cmd/bd/tips.go create mode 100644 cmd/bd/tips_example_test.go create mode 100644 cmd/bd/tips_test.go diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 5f4a6d0c..fb8fb77c 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -35,7 +35,7 @@ {"id":"bd-bwk2","content_hash":"c34c5f6281f61bd3298a0cfdaf11594a91e95decf680d655edf35854d1c94fe2","title":"Centralize error handling patterns in storage layer","description":"80+ instances of inconsistent error handling across sqlite.go with mix of %w, %v, and no wrapping.\n\nLocation: internal/storage/sqlite/sqlite.go (throughout)\n\nProblem:\n- Some use fmt.Errorf(\"op failed: %w\", err) - correct wrapping\n- Some use fmt.Errorf(\"op failed: %v\", err) - loses error chain\n- Some return err directly - no context\n- Hard to debug production issues\n- Can't distinguish error types\n\nSolution: Create internal/storage/sqlite/errors.go:\n- Define sentinel errors (ErrNotFound, ErrInvalidID, etc.)\n- Create wrapDBError(op string, err error) helper\n- Convert sql.ErrNoRows to ErrNotFound\n- Always wrap with operation context\n\nImpact: Lost error context; inconsistent messages; hard to debug\n\nEffort: 5-7 hours","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-16T14:51:54.974909-08:00","updated_at":"2025-11-24T01:05:29.606993-08:00","closed_at":"2025-11-24T00:57:47.359519-08:00","source_repo":".","dependencies":[{"issue_id":"bd-bwk2","depends_on_id":"bd-vfe","type":"blocks","created_at":"2025-11-24T00:53:28.831021-08:00","created_by":"daemon"},{"issue_id":"bd-bwk2","depends_on_id":"bd-z8z","type":"blocks","created_at":"2025-11-24T00:53:28.897593-08:00","created_by":"daemon"},{"issue_id":"bd-bwk2","depends_on_id":"bd-w8h","type":"blocks","created_at":"2025-11-24T00:53:28.957487-08:00","created_by":"daemon"},{"issue_id":"bd-bwk2","depends_on_id":"bd-r71","type":"blocks","created_at":"2025-11-24T00:53:29.023262-08:00","created_by":"daemon"}]} {"id":"bd-c362","content_hash":"c9b1c62aad1db56a65ae6b628ea6e3d54923c71436fe5eec1ea4daddfcd68fc2","title":"Extract database search logic into helper function","description":"The logic for finding a database in a beads directory is duplicated:\n- FindDatabasePath() BEADS_DIR section (beads.go:141-169)\n- findDatabaseInTree() (beads.go:248-280)\n\nBoth implement the same search order:\n1. Check config.json first (single source of truth)\n2. Fall back to canonical beads.db\n3. Search for *.db files, filtering backups and vc.db\n\nRefactoring suggestion:\nExtract to a helper function like:\n func findDatabaseInBeadsDir(beadsDir string) string\n\nBenefits:\n- Single source of truth for database search logic\n- Easier to maintain and update search order\n- Reduces code duplication\n\nRelated to [deleted:[deleted:[deleted:[deleted:[deleted:[deleted:[deleted:[deleted:[deleted:bd-e16b]]]]]]]]] implementation.","status":"open","priority":3,"issue_type":"chore","created_at":"2025-11-02T18:34:02.831543-08:00","updated_at":"2025-11-23T22:30:55.479057-08:00","source_repo":"."} {"id":"bd-c4rq","content_hash":"4c096e1d84c3ba5b5b4e107692b990a99166b4c99a4262fd26ec08297fb81046","title":"Refactor: Move staleness check inside daemon branch","description":"## Problem\n\nCurrently ensureDatabaseFresh() is called before the daemon mode check, but it checks daemonClient != nil internally and returns early. This is redundant.\n\n**Location:** All read commands (list.go:196, show.go:27, ready.go:102, status.go:80, etc.)\n\n## Current Pattern\n\nCall happens before daemon check, function checks daemonClient internally.\n\n## Better Pattern\n\nMove staleness check to direct mode branch only, after daemon check.\n\n## Impact\nLow - minor performance improvement (avoids one function call per command in daemon mode)\n\n## Effort\nMedium - requires refactoring 8 command files\n\n## Priority\nLow - can defer to future cleanup PR","status":"open","priority":3,"issue_type":"chore","created_at":"2025-11-20T20:17:45.119583-05:00","updated_at":"2025-11-20T20:17:45.119583-05:00","source_repo":"."} -{"id":"bd-d4i","content_hash":"41cafb4bfa5377a84005b08cddd3e703c1317e98ef32b050ddaabf1bdc7718c9","title":"Create tip system infrastructure for contextual hints","description":"Implement a tip/hint system that shows helpful contextual messages after successful commands. This is different from the existing error-path \"Hint:\" messages - tips appear on success paths to educate users about features they might not know about.","design":"## Implementation\n\nCreate `cmd/bd/tips.go` with:\n\n### Core Infrastructure\n```go\ntype Tip struct {\n ID string\n Condition func() bool // Should this tip be eligible?\n Message string\n Frequency time.Duration // Minimum gap between showings\n Priority int // Higher = shown first when eligible\n Probability float64 // 0.0 to 1.0 - chance of showing\n}\n\nfunc maybeShowTip(store storage.Storage) {\n if jsonOutput || quietMode {\n return // Respect output flags\n }\n \n tip := selectNextTip(store)\n if tip != nil {\n fmt.Fprintf(os.Stdout, \"\\n💡 Tip: %s\\n\", tip.Message)\n recordTipShown(store, tip.ID)\n }\n}\n\nfunc selectNextTip(store storage.Storage) *Tip {\n now := time.Now()\n var eligibleTips []Tip\n \n // Filter to eligible tips (condition + frequency check)\n for _, tip := range tips {\n if !tip.Condition() {\n continue\n }\n \n lastShown := getLastShown(store, tip.ID)\n if !lastShown.IsZero() \u0026\u0026 now.Sub(lastShown) \u003c tip.Frequency {\n continue\n }\n \n eligibleTips = append(eligibleTips, tip)\n }\n \n if len(eligibleTips) == 0 {\n return nil\n }\n \n // Sort by priority (highest first)\n sort.Slice(eligibleTips, func(i, j int) bool {\n return eligibleTips[i].Priority \u003e eligibleTips[j].Priority\n })\n \n // Apply probability roll (in priority order)\n for _, tip := range eligibleTips {\n if rand.Float64() \u003c tip.Probability {\n return \u0026tip\n }\n }\n \n return nil // No tips won probability roll\n}\n```\n\n### Probability Examples\n\n```go\n// High priority, high probability = shows often\n{Priority: 90, Probability: 0.8} // 80% chance when eligible\n\n// High priority, medium probability = important but not spammy\n{Priority: 100, Probability: 0.6} // 60% chance\n\n// Low priority, low probability = rare suggestion\n{Priority: 30, Probability: 0.3} // 30% chance\n```\n\n### Metadata Storage\nUse existing metadata table to track:\n- `tip_{id}_last_shown` - Timestamp of last display (RFC3339 format)\n- `tip_{id}_dismissed` - User permanently dismissed (future feature)\n\n### Integration Points\nCall `maybeShowTip()` at end of:\n- `bd list` - After showing issues\n- `bd ready` - After showing ready work\n- `bd create` - After creating issue\n- `bd show` - After showing issue details\n\n## Design Decisions\n- Tips shown on stdout (informational, not errors)\n- Respects `--json` and `--quiet` flags\n- Frequency enforces minimum gap between showings\n- Priority determines evaluation order\n- Probability reduces spam (not every eligible tip shows)\n- Store state in metadata table (no new files)\n- Deterministic seed for testing (optional BEADS_TIP_SEED env var)","acceptance_criteria":"- Tip infrastructure exists in cmd/bd/tips.go\n- Tips respect --json and --quiet flags\n- Frequency tracking works (no spam)\n- Metadata table stores tip state\n- Unit tests for tip selection logic\n- Documentation in code comments","status":"open","priority":2,"issue_type":"feature","created_at":"2025-11-11T23:29:15.693956-08:00","updated_at":"2025-11-11T23:49:50.812933-08:00","source_repo":"."} +{"id":"bd-d4i","content_hash":"0f4a6fc78e5f1647bec436637537899331a3c56cbec637202a12e4b51612f5fb","title":"Create tip system infrastructure for contextual hints","description":"Implement a tip/hint system that shows helpful contextual messages after successful commands. This is different from the existing error-path \"Hint:\" messages - tips appear on success paths to educate users about features they might not know about.","design":"## Implementation\n\nCreate `cmd/bd/tips.go` with:\n\n### Core Infrastructure\n```go\ntype Tip struct {\n ID string\n Condition func() bool // Should this tip be eligible?\n Message string\n Frequency time.Duration // Minimum gap between showings\n Priority int // Higher = shown first when eligible\n Probability float64 // 0.0 to 1.0 - chance of showing\n}\n\nfunc maybeShowTip(store storage.Storage) {\n if jsonOutput || quietMode {\n return // Respect output flags\n }\n \n tip := selectNextTip(store)\n if tip != nil {\n fmt.Fprintf(os.Stdout, \"\\n💡 Tip: %s\\n\", tip.Message)\n recordTipShown(store, tip.ID)\n }\n}\n\nfunc selectNextTip(store storage.Storage) *Tip {\n now := time.Now()\n var eligibleTips []Tip\n \n // Filter to eligible tips (condition + frequency check)\n for _, tip := range tips {\n if !tip.Condition() {\n continue\n }\n \n lastShown := getLastShown(store, tip.ID)\n if !lastShown.IsZero() \u0026\u0026 now.Sub(lastShown) \u003c tip.Frequency {\n continue\n }\n \n eligibleTips = append(eligibleTips, tip)\n }\n \n if len(eligibleTips) == 0 {\n return nil\n }\n \n // Sort by priority (highest first)\n sort.Slice(eligibleTips, func(i, j int) bool {\n return eligibleTips[i].Priority \u003e eligibleTips[j].Priority\n })\n \n // Apply probability roll (in priority order)\n for _, tip := range eligibleTips {\n if rand.Float64() \u003c tip.Probability {\n return \u0026tip\n }\n }\n \n return nil // No tips won probability roll\n}\n```\n\n### Probability Examples\n\n```go\n// High priority, high probability = shows often\n{Priority: 90, Probability: 0.8} // 80% chance when eligible\n\n// High priority, medium probability = important but not spammy\n{Priority: 100, Probability: 0.6} // 60% chance\n\n// Low priority, low probability = rare suggestion\n{Priority: 30, Probability: 0.3} // 30% chance\n```\n\n### Metadata Storage\nUse existing metadata table to track:\n- `tip_{id}_last_shown` - Timestamp of last display (RFC3339 format)\n- `tip_{id}_dismissed` - User permanently dismissed (future feature)\n\n### Integration Points\nCall `maybeShowTip()` at end of:\n- `bd list` - After showing issues\n- `bd ready` - After showing ready work\n- `bd create` - After creating issue\n- `bd show` - After showing issue details\n\n## Design Decisions\n- Tips shown on stdout (informational, not errors)\n- Respects `--json` and `--quiet` flags\n- Frequency enforces minimum gap between showings\n- Priority determines evaluation order\n- Probability reduces spam (not every eligible tip shows)\n- Store state in metadata table (no new files)\n- Deterministic seed for testing (optional BEADS_TIP_SEED env var)","acceptance_criteria":"- Tip infrastructure exists in cmd/bd/tips.go\n- Tips respect --json and --quiet flags\n- Frequency tracking works (no spam)\n- Metadata table stores tip state\n- Unit tests for tip selection logic\n- Documentation in code comments","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2025-11-11T23:29:15.693956-08:00","updated_at":"2025-11-24T10:50:46.824307-08:00","source_repo":"."} {"id":"bd-e1085716","content_hash":"a9385e9f00bc41e5e2258fdfccd9f2cbd5a702764b5f1d036274e6026f8c3e38","title":"bd validate - Comprehensive health check","description":"Run all validation checks in one command.\n\nChecks:\n- Duplicates\n- Orphaned dependencies\n- Test pollution\n- Git conflicts\n\nSupports --fix-all for auto-repair.\n\nDepends on bd-cbed9619.1, bd-0dcea000, bd-31aab707, bd-9826b69a.\n\nFiles: cmd/bd/validate.go (new)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T23:05:13.980679-07:00","updated_at":"2025-11-23T23:52:29.99642-08:00","closed_at":"2025-11-23T23:20:39.694984-08:00","source_repo":"."} {"id":"bd-e166","content_hash":"000f4f9d069ffedceae13894d967ec30fa4a89e318bfcac4847f3c3b16d44a89","title":"Improve timestamp comparison readability in import","description":"The timestamp comparison logic uses double-negative which can be confusing:\n\nCurrent code:\nif !incoming.UpdatedAt.After(existing.UpdatedAt) {\n // skip update\n}\n\nMore readable:\nif incoming.UpdatedAt.After(existing.UpdatedAt) {\n // perform update\n} else {\n // skip (local is newer)\n}\n\nThis is a minor refactor for code clarity.\n\nRelated: bd-1022\nFiles: internal/importer/importer.go:411, 488","status":"open","priority":4,"issue_type":"chore","created_at":"2025-11-02T15:32:12.27108-08:00","updated_at":"2025-11-02T15:32:12.27108-08:00","source_repo":"."} {"id":"bd-e92","content_hash":"12073b3293b06f99051bc9c00188aeb520cd2e4792cf4694f1fa4b784e625e54","title":"Add test coverage for internal/autoimport package","description":"","design":"The autoimport package has only 1 test file. Need comprehensive tests. Target: 70% coverage","acceptance_criteria":"- At least 3 test files\n- Package coverage \u003e= 70%\n- Tests cover main functionality, error paths, edge cases","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-20T21:21:22.338577-05:00","updated_at":"2025-11-20T21:21:22.338577-05:00","source_repo":".","dependencies":[{"issue_id":"bd-e92","depends_on_id":"bd-ge7","type":"blocks","created_at":"2025-11-20T21:21:31.128625-05:00","created_by":"daemon"}]} diff --git a/cmd/bd/create.go b/cmd/bd/create.go index e18f984a..9eae8260 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -395,6 +395,9 @@ var createCmd = &cobra.Command{ fmt.Printf(" Title: %s\n", issue.Title) fmt.Printf(" Priority: P%d\n", issue.Priority) fmt.Printf(" Status: %s\n", issue.Status) + + // Show tip after successful create (direct mode only) + maybeShowTip(store) } }, } diff --git a/cmd/bd/list.go b/cmd/bd/list.go index cf63fce5..20964b10 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -447,6 +447,9 @@ var listCmd = &cobra.Command{ assigneeStr, labelsStr, issue.Title) } } + + // Show tip after successful list (direct mode only) + maybeShowTip(store) }, } diff --git a/cmd/bd/ready.go b/cmd/bd/ready.go index 76cf2201..681445dd 100644 --- a/cmd/bd/ready.go +++ b/cmd/bd/ready.go @@ -142,6 +142,8 @@ var readyCmd = &cobra.Command{ yellow := color.New(color.FgYellow).SprintFunc() fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n", yellow("✨")) + // Show tip even when no ready work found + maybeShowTip(store) return } cyan := color.New(color.FgCyan).SprintFunc() @@ -156,6 +158,9 @@ var readyCmd = &cobra.Command{ } } fmt.Println() + + // Show tip after successful ready (direct mode only) + maybeShowTip(store) }, } var blockedCmd = &cobra.Command{ diff --git a/cmd/bd/show.go b/cmd/bd/show.go index 499886f4..4ff81afd 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -335,6 +335,9 @@ var showCmd = &cobra.Command{ if jsonOutput && len(allDetails) > 0 { outputJSON(allDetails) + } else if len(allDetails) > 0 { + // Show tip after successful show (non-JSON mode) + maybeShowTip(store) } }, } diff --git a/cmd/bd/tips.go b/cmd/bd/tips.go new file mode 100644 index 00000000..6267ef8c --- /dev/null +++ b/cmd/bd/tips.go @@ -0,0 +1,154 @@ +package main + +import ( + "context" + "fmt" + "math/rand" + "os" + "sort" + "strconv" + "sync" + "time" + + "github.com/steveyegge/beads/internal/storage" +) + +// Tip represents a contextual hint that can be shown to users after successful commands +type Tip struct { + ID string + Condition func() bool // Should this tip be eligible? + Message string // The tip message to display + Frequency time.Duration // Minimum gap between showings + Priority int // Higher = shown first when eligible + Probability float64 // 0.0 to 1.0 - chance of showing when eligible +} + +var ( + // tips is the registry of all available tips + tips []Tip + + // tipsMutex protects the tips registry for thread-safe access + tipsMutex sync.RWMutex + + // tipRand is the random number generator for probability rolls + // Can be seeded deterministically via BEADS_TIP_SEED for testing + tipRand *rand.Rand + + // tipRandOnce ensures we only initialize the RNG once + tipRandOnce sync.Once +) + +// initTipRand initializes the random number generator for tip selection +// Uses BEADS_TIP_SEED env var for deterministic testing if set +func initTipRand() { + tipRandOnce.Do(func() { + seed := time.Now().UnixNano() + if seedStr := os.Getenv("BEADS_TIP_SEED"); seedStr != "" { + if parsedSeed, err := strconv.ParseInt(seedStr, 10, 64); err == nil { + seed = parsedSeed + } + } + // Use deprecated rand.NewSource for Go 1.19 compatibility + // nolint:gosec,staticcheck // G404: deterministic seed via env var is intentional for testing + tipRand = rand.New(rand.NewSource(seed)) + }) +} + +// maybeShowTip selects and displays an eligible tip based on priority and probability +// Respects --json and --quiet flags +func maybeShowTip(store storage.Storage) { + // Skip tips in JSON output mode or quiet mode + if jsonOutput || quietFlag { + return + } + + // Initialize RNG if needed + initTipRand() + + // Select next tip + tip := selectNextTip(store) + if tip == nil { + return + } + + // Display tip to stdout (informational, not an error) + fmt.Fprintf(os.Stdout, "\n💡 Tip: %s\n", tip.Message) + + // Record that we showed this tip + recordTipShown(store, tip.ID) +} + +// selectNextTip finds the next tip to show based on conditions, frequency, priority, and probability +// Returns nil if no tip should be shown +func selectNextTip(store storage.Storage) *Tip { + if store == nil { + return nil + } + + now := time.Now() + var eligibleTips []Tip + + // Lock for reading the tip registry + tipsMutex.RLock() + defer tipsMutex.RUnlock() + + // Filter to eligible tips (condition + frequency check) + for _, tip := range tips { + // Check if tip's condition is met + if !tip.Condition() { + continue + } + + // Check if enough time has passed since last showing + lastShown := getLastShown(store, tip.ID) + if !lastShown.IsZero() && now.Sub(lastShown) < tip.Frequency { + continue + } + + eligibleTips = append(eligibleTips, tip) + } + + if len(eligibleTips) == 0 { + return nil + } + + // Sort by priority (highest first) + sort.Slice(eligibleTips, func(i, j int) bool { + return eligibleTips[i].Priority > eligibleTips[j].Priority + }) + + // Apply probability roll (in priority order) + // Higher priority tips get first chance to show + for i := range eligibleTips { + if tipRand.Float64() < eligibleTips[i].Probability { + return &eligibleTips[i] + } + } + + return nil // No tips won probability roll +} + +// getLastShown retrieves the timestamp when a tip was last shown +// Returns zero time if never shown +func getLastShown(store storage.Storage, tipID string) time.Time { + key := fmt.Sprintf("tip_%s_last_shown", tipID) + value, err := store.GetMetadata(context.Background(), key) + if err != nil || value == "" { + return time.Time{} + } + + // Parse RFC3339 timestamp + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{} + } + + return t +} + +// recordTipShown records the timestamp when a tip was shown +func recordTipShown(store storage.Storage, tipID string) { + key := fmt.Sprintf("tip_%s_last_shown", tipID) + value := time.Now().Format(time.RFC3339) + _ = store.SetMetadata(context.Background(), key, value) +} diff --git a/cmd/bd/tips_example_test.go b/cmd/bd/tips_example_test.go new file mode 100644 index 00000000..61f1f25d --- /dev/null +++ b/cmd/bd/tips_example_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "testing" + "time" +) + +// This file demonstrates example tip definitions for documentation purposes + +func TestExampleTipDefinitions(t *testing.T) { + // Example 1: High priority, high probability tip + // Shows frequently when condition is met + highPriorityTip := Tip{ + ID: "example_high_priority", + Condition: func() bool { return true }, + Message: "This is an important tip that shows often", + Frequency: 24 * time.Hour, // Show at most once per day + Priority: 100, // Highest priority + Probability: 0.8, // 80% chance when eligible + } + + // Example 2: Medium priority, medium probability tip + // General feature discovery + mediumPriorityTip := Tip{ + ID: "example_medium_priority", + Condition: func() bool { return true }, + Message: "Try using 'bd ready' to see available work", + Frequency: 7 * 24 * time.Hour, // Show at most once per week + Priority: 50, // Medium priority + Probability: 0.5, // 50% chance when eligible + } + + // Example 3: Low priority, low probability tip + // Nice-to-know information + lowPriorityTip := Tip{ + ID: "example_low_priority", + Condition: func() bool { return true }, + Message: "You can filter issues by label with --label flag", + Frequency: 30 * 24 * time.Hour, // Show at most once per month + Priority: 10, // Low priority + Probability: 0.2, // 20% chance when eligible + } + + // Example 4: Conditional tip + // Only shows when specific condition is true + conditionalTip := Tip{ + ID: "example_conditional", + Condition: func() bool { + // Example: Only show if some condition is met + // In real usage, this might check for specific state + return false // Disabled for this example + }, + Message: "This tip only shows when condition is met", + Frequency: 24 * time.Hour, + Priority: 80, + Probability: 0.6, + } + + // Verify tips are properly structured (basic validation) + tips := []Tip{highPriorityTip, mediumPriorityTip, lowPriorityTip, conditionalTip} + + for _, tip := range tips { + if tip.ID == "" { + t.Error("Tip ID should not be empty") + } + if tip.Message == "" { + t.Error("Tip message should not be empty") + } + if tip.Condition == nil { + t.Error("Tip condition function should not be nil") + } + if tip.Frequency < 0 { + t.Error("Tip frequency should not be negative") + } + if tip.Probability < 0 || tip.Probability > 1 { + t.Errorf("Tip probability should be between 0 and 1, got %f", tip.Probability) + } + } +} + +// Example showing probability guidelines +func TestProbabilityGuidelines(t *testing.T) { + examples := []struct { + name string + probability float64 + useCase string + }{ + {"Critical", 1.0, "Security alerts, breaking changes"}, + {"High", 0.8, "Important updates, major features"}, + {"Medium", 0.5, "General tips, workflow improvements"}, + {"Low", 0.2, "Nice-to-know, advanced features"}, + } + + for _, ex := range examples { + if ex.probability < 0 || ex.probability > 1 { + t.Errorf("%s: probability %f out of range", ex.name, ex.probability) + } + } +} diff --git a/cmd/bd/tips_test.go b/cmd/bd/tips_test.go new file mode 100644 index 00000000..2f2fadeb --- /dev/null +++ b/cmd/bd/tips_test.go @@ -0,0 +1,274 @@ +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") + } +}