Files
iris-context/internal/scoring/scorer_test.go
VERSE 6ffb9f83f2 feat(scoring): transition workflow learning and fix history UX (#39)
- add transition scoring engine to learn sequential workflows per
directory using command skeletons
- prioritize current active git branch over older branches in
suggestions
- restore strict chronological order for history navigation and bypass
AI re-ranking
- add in-memory session history for instant access to just-run commands
- fix PTY prompt not syncing when using arrow keys in the history menu
- fix history tie-breaker to properly prioritize newer commands by
assigning larger IDs
- fix potential mutex deadlock between bufferMu and pty write during
history navigation
- fix workspace git detection to remove depth limits and correctly
identify repositories with detached HEADs
- fix SQLite connection leaks in frecency queries
- fix global state leaks in history tests by snapshotting and restoring
registry states
- modernize string prefix checks using strings.CutPrefix
2026-07-26 19:10:56 +07:00

239 lines
7.2 KiB
Go

package scoring
import (
"testing"
"time"
"github.com/versenilvis/iris/internal/workspace"
"github.com/versenilvis/iris/spec"
)
func TestScore_GitInitAndStatusInGitRepo(t *testing.T) {
suggestions := []spec.Suggestion{
{Cmd: "git init", Source: "spec"},
{Cmd: "git status -s", Source: "spec"},
}
signals := SignalSet{
Workspace: workspace.WorkspaceInfo{HasGit: true},
Query: "git",
}
scored := Score(suggestions, signals)
if len(scored) != 2 {
t.Fatalf("expected 2 scored suggestions, got %d", len(scored))
}
if scored[0].Cmd != "git status -s" {
t.Errorf("expected 'git status -s' at top when inside git repo, got %s", scored[0].Cmd)
}
if scored[1].Cmd != "git init" {
t.Errorf("expected 'git init' at bottom, got %s", scored[1].Cmd)
}
if scored[1].Breakdown.ContextBonus != -50 {
t.Errorf("expected -50 penalty for git init, got %d", scored[1].Breakdown.ContextBonus)
}
}
func TestScore_NormalizedFrecency(t *testing.T) {
suggestions := []spec.Suggestion{
{Cmd: "ls -la", Source: "history"},
{Cmd: "git push", Source: "history"},
}
now := time.Now()
signals := SignalSet{
LocalFrecency: []FrecencyEntry{
{Cmd: "ls -la", RawScore: 25000.0, LastUsed: now.Add(-30 * 24 * time.Hour)},
{Cmd: "git push", RawScore: 500.0, LastUsed: now},
},
}
scored := Score(suggestions, signals)
var lsBreakdown, pushBreakdown ScoreBreakdown
for _, s := range scored {
switch s.Cmd {
case "ls -la":
lsBreakdown = s.Breakdown
case "git push":
pushBreakdown = s.Breakdown
}
}
if lsBreakdown.Frecency != 100 {
t.Errorf("expected max raw score to normalize to 100, got %d", lsBreakdown.Frecency)
}
if pushBreakdown.Frecency <= 0 || pushBreakdown.Frecency > 100 {
t.Errorf("expected normalized frecency in (0, 100], got %d", pushBreakdown.Frecency)
}
}
func TestScore_PrefixOverFuzzyMatch(t *testing.T) {
suggestions := []spec.Suggestion{
{Cmd: "make build", Source: "spec"}, // fuzzy/contains for 'bl'
{Cmd: "block", Source: "spec"}, // prefix exact for 'bl'
}
signals := SignalSet{Query: "bl"}
scored := Score(suggestions, signals)
if len(scored) != 2 {
t.Fatalf("expected 2 scored suggestions, got %d", len(scored))
}
if scored[0].Cmd != "block" {
t.Errorf("expected prefix match 'block' to outscore fuzzy 'make build', got %s", scored[0].Cmd)
}
}
func TestScore_AISuggestionConfidence(t *testing.T) {
suggestions := []spec.Suggestion{
{Cmd: "npm run custom-script", Source: "ai", Confidence: 85},
{Cmd: "npm help", Source: "history"},
}
signals := SignalSet{
Workspace: workspace.WorkspaceInfo{HasNodeProject: true},
Query: "npm",
}
scored := Score(suggestions, signals)
if len(scored) < 1 {
t.Fatalf("expected scored suggestions")
}
if scored[0].Cmd != "npm run custom-script" {
t.Errorf("expected high-confidence AI suggestion with context bonus at top, got %s", scored[0].Cmd)
}
}
func TestScore_UnsortedHistorySorting(t *testing.T) {
suggestions := []spec.Suggestion{
{Cmd: "cmdA", Source: "history"},
{Cmd: "cmdB", Source: "history"},
{Cmd: "cmdC", Source: "history"},
}
signals := SignalSet{
LocalFrecency: []FrecencyEntry{
{Cmd: "cmdC", RawScore: 100.0},
{Cmd: "cmdA", RawScore: 50.0},
{Cmd: "cmdB", RawScore: 10.0},
},
}
scored := Score(suggestions, signals)
if len(scored) != 3 {
t.Fatalf("expected 3 items, got %d", len(scored))
}
if scored[0].Cmd != "cmdC" || scored[1].Cmd != "cmdA" || scored[2].Cmd != "cmdB" {
t.Errorf("expected cmdC > cmdA > cmdB based on frecency, got %s, %s, %s", scored[0].Cmd, scored[1].Cmd, scored[2].Cmd)
}
}
func TestBasePriorityFor_HistoryWithConfidence(t *testing.T) {
s1 := spec.Suggestion{Source: "history"}
if p := basePriorityFor(s1); p != 40 {
t.Errorf("expected default history priority 40 when confidence unset, got %d", p)
}
s2 := spec.Suggestion{Source: "history", Confidence: 85}
if p := basePriorityFor(s2); p != 85 {
t.Errorf("expected history priority 85 when confidence is 85, got %d", p)
}
s3 := spec.Suggestion{Source: "history", Confidence: 150}
if p := basePriorityFor(s3); p != 100 {
t.Errorf("expected capped history priority 100 when confidence is > 100, got %d", p)
}
}
func TestIsSubsequence_UTF8(t *testing.T) {
_ = isSubsequence("gố", "gõ tiếng việt")
if !isSubsequence("việt", "tiếng việt") {
t.Error("expected 'việt' to be subsequence of 'tiếng việt'")
}
if !isSubsequence("tệt", "tiếng việt") {
t.Error("expected 'tệt' to be subsequence of 'tiếng việt'")
}
if isSubsequence("xyz", "tiếng việt") {
t.Error("expected 'xyz' NOT to be subsequence of 'tiếng việt'")
}
if !isSubsequence("αγ", "αβγδε") {
t.Error("expected multi-byte rune 'αγ' to be subsequence of 'αβγδε'")
}
}
func TestScore_TransitionAndColdStartGuard(t *testing.T) {
spec.ResetRegistry()
spec.Register(&spec.Spec{
Name: "git",
Subcommands: []spec.Subcommand{
{Name: "pull"},
{Name: "status"},
},
})
suggestions := []spec.Suggestion{
{Cmd: "git pull --rebase origin main", Source: "spec"},
{Cmd: "git status", Source: "spec"},
}
// Cold-start test: nil/empty TransitionEntries should not panic and contribute 0
signalsCold := SignalSet{
Query: "git",
TransitionEntries: nil,
}
scoredCold := Score(suggestions, signalsCold)
if len(scoredCold) != 2 || scoredCold[0].Breakdown.Transition != 0 {
t.Errorf("expected 0 transition score on cold-start without panic, got %v", scoredCold)
}
// Transition Local test
signalsLocal := SignalSet{
Query: "git",
TransitionEntries: []TransitionEntry{{NextSkeleton: "git pull", Count: 10}},
TransitionIsLocal: true,
}
scoredLocal := Score(suggestions, signalsLocal)
if scoredLocal[0].Breakdown.Transition != 100 {
t.Errorf("expected transition score 100 for git pull when local, got %d", scoredLocal[0].Breakdown.Transition)
}
// Transition Global test (damping 70%)
signalsGlobal := SignalSet{
Query: "git",
TransitionEntries: []TransitionEntry{{NextSkeleton: "git pull", Count: 10}},
TransitionIsLocal: false,
}
scoredGlobal := Score(suggestions, signalsGlobal)
if scoredGlobal[0].Breakdown.Transition != 70 {
t.Errorf("expected transition score 70 for git pull when global (70%% damping), got %d", scoredGlobal[0].Breakdown.Transition)
}
}
func TestScore_TieBreakingOrder(t *testing.T) {
// All three suggestions will be constructed to have identical total scores (0),
// but different breakdown scores to verify tie-break priority: Transition > Frecency > ContextBonus > Alphabetical.
suggestions := []spec.Suggestion{
{Cmd: "cmdA", Source: "test", Priority: 0},
{Cmd: "cmdB", Source: "test", Priority: 0},
}
// We set weight configuration with 0 weights so total scores are identical (0), triggering tie-break logic
zeroConfig := ScoreConfig{
WeightBasePriority: 0,
WeightContextBonus: 0,
WeightFrecency: 0,
WeightTransition: 0,
WeightMatchQuality: 0,
}
signals := SignalSet{
Query: "",
TransitionEntries: []TransitionEntry{
{NextSkeleton: "cmdB", Count: 10},
{NextSkeleton: "cmdA", Count: 5},
},
TransitionIsLocal: true,
}
scored := ScoreWithConfig(suggestions, signals, zeroConfig)
if len(scored) != 2 || scored[0].Cmd != "cmdB" {
t.Errorf("expected cmdB to win tie-break due to higher Transition score, got %v", scored)
}
}