Files
iris-context/tests/ai/context_test.go
T
VERSE 94c8af7b1d feat: AI suggestion (#34)
## Feat
* feat(ai): add AI suggestion engine, context gathering, and ghost text
overlay (`v0.3.0`)

## Security & Perf
* fix(ai): restrict background `--help` execution to a hardcoded command
allowlist to prevent RCE
* fix(root): defer context cancellation in goroutine to prevent resource
leaks
* perf(ai): implement LRU eviction and maximum size limit for
`ProviderCache`
* perf(ai): truncate command context output
(`CommandContextProvider.Gather`) to 1000 characters to save tokens

## Bug Fixes
* fix(ai): add mutex synchronization and thread-safe snapshotting to
`AIEngine.RegisterProvider` and `GatherDynamicContext`
* fix(ai): return a copy of fresh `Suggestion` in `AIEngine.Suggest` to
prevent cache mutation
* fix(ai): use length-prefixed encoding in `EnvSnapshot.Hash` to prevent
delimiter collisions
* fix(ai): skip variable assignment lines containing `=` when extracting
Makefile targets
* fix(runner): check `scanner.Err()` and return `nil` on scan errors in
justfile generator
* test(ci): rename `git commit` to `git checkout` in overlay test to
resolve CI `typos` false positive
2026-07-11 15:17:14 +07:00

123 lines
4.3 KiB
Go

package tests
import (
"context"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/versenilvis/iris/ai"
"github.com/versenilvis/iris/spec"
)
type mockAISuggester struct {
calls int32
ret *spec.Suggestion
}
func (m *mockAISuggester) SuggestOnEmpty(ctx context.Context, env ai.EnvSnapshot) (*spec.Suggestion, error) {
atomic.AddInt32(&m.calls, 1)
return m.ret, nil
}
func TestRuleBasedSuggester(t *testing.T) {
rule := ai.RuleBasedSuggester{}
ctx := context.Background()
// case 1: retry failed command
sugg1, _ := rule.SuggestOnEmpty(ctx, ai.EnvSnapshot{LastExitCode: 1, LastCmd: "make build"})
if sugg1 == nil || sugg1.Cmd != "make build" || sugg1.Confidence != 80 {
t.Fatalf("expected retry make build with conf 80, got: %+v", sugg1)
}
// case 2: git status after git status
sugg2, _ := rule.SuggestOnEmpty(ctx, ai.EnvSnapshot{LastCmd: "git status"})
if sugg2 == nil || sugg2.Cmd != "git diff" || sugg2.Confidence != 75 {
t.Fatalf("expected git diff with conf 75, got: %+v", sugg2)
}
// case 3: modified git files
sugg3, _ := rule.SuggestOnEmpty(ctx, ai.EnvSnapshot{GitStatus: " M main.go"})
if sugg3 == nil || sugg3.Cmd != "git status" || sugg3.Confidence != 70 {
t.Fatalf("expected git status with conf 70, got: %+v", sugg3)
}
// case 4: package.json signature (conf 65)
sugg4, _ := rule.SuggestOnEmpty(ctx, ai.EnvSnapshot{DirSignature: "package.json"})
if sugg4 == nil || sugg4.Cmd != "npm run dev" || sugg4.Confidence != 65 {
t.Fatalf("expected npm run dev with conf 65, got: %+v", sugg4)
}
}
func TestContextCache_ShouldCallAI(t *testing.T) {
cache := ai.NewContextCache()
snap := ai.EnvSnapshot{Cwd: "/test", GitStatus: "clean"}
// first call -> true
if !cache.ShouldCallAI(snap, 50*time.Millisecond) {
t.Fatalf("expected true for initial call")
}
cache.Update(snap, &spec.Suggestion{Cmd: "test"})
// second call with same snap -> false
if cache.ShouldCallAI(snap, 50*time.Millisecond) {
t.Fatalf("expected false when hash has not changed")
}
// wait for min interval before calling again
time.Sleep(60 * time.Millisecond)
// third call with different snap -> true
snap.GitStatus = "dirty"
if !cache.ShouldCallAI(snap, 50*time.Millisecond) {
t.Fatalf("expected true when hash changed")
}
}
func TestEmptyLinePredictor_TwoTier(t *testing.T) {
ctx := context.Background()
mockAI := &mockAISuggester{ret: &spec.Suggestion{Cmd: "ai-suggested-cmd", Confidence: 85}}
predictor := ai.NewEmptyLinePredictor(nil, mockAI, 50*time.Millisecond)
// case 1: rule based match >= 70 -> ai not called
env1 := ai.EnvSnapshot{LastExitCode: 1, LastCmd: "failed-cmd"}
sugg1, _ := predictor.Predict(ctx, env1, true)
if sugg1 == nil || sugg1.Cmd != "failed-cmd" || atomic.LoadInt32(&mockAI.calls) != 0 {
t.Fatalf("expected rule based result and 0 ai calls, got sugg: %+v, calls: %d", sugg1, mockAI.calls)
}
// case 2: rule based conf < 70 -> ai called
env2 := ai.EnvSnapshot{DirSignature: "package.json"}
sugg2, _ := predictor.Predict(ctx, env2, true)
if sugg2 == nil || sugg2.Cmd != "ai-suggested-cmd" || atomic.LoadInt32(&mockAI.calls) != 1 {
t.Fatalf("expected ai result and 1 ai call, got sugg: %+v, calls: %d", sugg2, mockAI.calls)
}
// case 3: same env as case 2 immediately -> ai not called again (cached)
sugg3, _ := predictor.Predict(ctx, env2, true)
if sugg3 == nil || sugg3.Cmd != "ai-suggested-cmd" || atomic.LoadInt32(&mockAI.calls) != 1 {
t.Fatalf("expected cached ai result and 1 ai call (no increment), got calls: %d", mockAI.calls)
}
}
// Verify that Makefile target extraction ignores variable assignments containing operators like := or colons in values
func TestExtractScriptsAndTargets_Makefile(t *testing.T) {
tmp := t.TempDir()
content := []byte("CFLAGS := -O2\nPREFIX ?= /usr/local\nPATH = /bin:/usr/bin\nall: build\nbuild:\n\techo build\n")
_ = os.WriteFile(filepath.Join(tmp, "Makefile"), content, 0644)
var sb strings.Builder
ai.ExtractScriptsAndTargets(&sb, tmp, "")
res := sb.String()
if !strings.Contains(res, "build") || !strings.Contains(res, "all") {
t.Fatalf("expected real targets build and all in result, got: %q", res)
}
if strings.Contains(res, "CFLAGS") || strings.Contains(res, "PREFIX") || strings.Contains(res, "PATH") {
t.Fatalf("expected variable assignments CFLAGS, PREFIX, PATH to be skipped, got: %q", res)
}
}