Files
beads/internal/storage/sqlite/adaptive_length_test.go
Steve Yegge b4cb636d92 Switch from hex to Base36 encoding for issue IDs (GH #213)
This change improves information density by using Base36 (0-9, a-z) instead
of hex (0-9, a-f) for hash-based issue IDs. Key benefits:

- Shorter IDs: Can now use 3-char IDs (was 4-char minimum)
- Better scaling: 3 chars good for ~160 issues, 4 chars for ~980 issues
- Case-insensitive: Maintains excellent CLI usability
- Backward compatible: Old hex IDs continue to work

Changes:
- Implemented Base36 encoding with proper truncation (keep LSB)
- Updated adaptive length thresholds (3-8 chars instead of 4-8)
- Fixed collision probability math to match encoding (was calculating
  for base36 but encoding in hex - now both use base36)
- Fixed ID parser bug (use prefixWithHyphen for substring matching)
- Updated all tests and test data patterns

Fixes #213

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 12:02:15 -08:00

196 lines
4.4 KiB
Go

package sqlite
import (
"context"
"fmt"
"strings"
"testing"
"time"
)
func TestCollisionProbability(t *testing.T) {
tests := []struct {
numIssues int
idLength int
expected float64 // approximate
}{
{50, 4, 0.0007}, // ~0.07%
{500, 4, 0.0717}, // ~7.17%
{1000, 5, 0.0082}, // ~0.82%
{1000, 6, 0.0002}, // ~0.02%
}
for _, tt := range tests {
got := collisionProbability(tt.numIssues, tt.idLength)
// Allow 20% tolerance for approximation (birthday paradox is an approximation)
diff := got - tt.expected
if diff < 0 {
diff = -diff
}
tolerance := tt.expected * 0.2
if diff > tolerance {
t.Errorf("collisionProbability(%d, %d) = %f, want ~%f (diff: %f)",
tt.numIssues, tt.idLength, got, tt.expected, diff)
}
}
}
func TestComputeAdaptiveLength(t *testing.T) {
tests := []struct {
name string
numIssues int
config AdaptiveIDConfig
want int
}{
{
name: "tiny database uses 3 chars",
numIssues: 50,
config: DefaultAdaptiveConfig(),
want: 3,
},
{
name: "small database uses 4 chars",
numIssues: 500,
config: DefaultAdaptiveConfig(),
want: 4,
},
{
name: "medium database uses 5 chars",
numIssues: 3000,
config: DefaultAdaptiveConfig(),
want: 5,
},
{
name: "large database uses 6 chars",
numIssues: 20000,
config: DefaultAdaptiveConfig(),
want: 6,
},
{
name: "very large database uses 7 chars",
numIssues: 100000,
config: DefaultAdaptiveConfig(),
want: 7,
},
{
name: "custom threshold - stricter",
numIssues: 200,
config: AdaptiveIDConfig{
MaxCollisionProbability: 0.01, // 1% threshold
MinLength: 3,
MaxLength: 8,
},
want: 5,
},
{
name: "custom threshold - more lenient",
numIssues: 1000,
config: AdaptiveIDConfig{
MaxCollisionProbability: 0.50, // 50% threshold
MinLength: 3,
MaxLength: 8,
},
want: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeAdaptiveLength(tt.numIssues, tt.config)
if got != tt.want {
t.Errorf("computeAdaptiveLength(%d) = %d, want %d",
tt.numIssues, got, tt.want)
}
})
}
}
func TestGenerateHashID_VariableLengths(t *testing.T) {
prefix := "bd"
title := "Test issue"
description := "Test description"
creator := "test@example.com"
timestamp, _ := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z")
tests := []struct {
length int
expectedLen int // length of hash portion (without prefix)
}{
{3, 3},
{4, 4},
{5, 5},
{6, 6},
{7, 7},
{8, 8},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("length_%d", tt.length), func(t *testing.T) {
id := generateHashID(prefix, title, description, creator, timestamp, tt.length, 0)
// Format: "bd-xxxx" where xxxx is the hash
if !strings.HasPrefix(id, prefix+"-") {
t.Errorf("ID should start with %s-, got %s", prefix, id)
}
hashPart := strings.TrimPrefix(id, prefix+"-")
if len(hashPart) != tt.expectedLen {
t.Errorf("Hash length = %d, want %d (full ID: %s)",
len(hashPart), tt.expectedLen, id)
}
})
}
}
func TestGetAdaptiveIDLength_Integration(t *testing.T) {
// Create in-memory database
db, err := New(":memory:")
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
defer db.Close()
ctx := context.Background()
// Initialize with prefix
if err := db.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set prefix: %v", err)
}
// Test default config (should use 3 chars for empty database)
conn, err := db.db.Conn(ctx)
if err != nil {
t.Fatalf("Failed to get connection: %v", err)
}
defer conn.Close()
length, err := GetAdaptiveIDLength(ctx, conn, "test")
if err != nil {
t.Fatalf("GetAdaptiveIDLength failed: %v", err)
}
if length != 3 {
t.Errorf("Empty database should use 3 chars, got %d", length)
}
// Test custom config
if err := db.SetConfig(ctx, "max_collision_prob", "0.01"); err != nil {
t.Fatalf("Failed to set max_collision_prob: %v", err)
}
if err := db.SetConfig(ctx, "min_hash_length", "5"); err != nil {
t.Fatalf("Failed to set min_hash_length: %v", err)
}
length, err = GetAdaptiveIDLength(ctx, conn, "test")
if err != nil {
t.Fatalf("GetAdaptiveIDLength with custom config failed: %v", err)
}
if length < 5 {
t.Errorf("With min_hash_length=5, got %d", length)
}
}