refactor: better project structure (#35)

This project is starting to grow, and I've seen flaws in the initial
project structure design
I think I'll restructure the entire project directory for better
scalability and also cleaner
This commit is contained in:
VERSE
2026-07-11 16:15:05 +07:00
committed by GitHub
parent 94c8af7b1d
commit 1e04949242
52 changed files with 339 additions and 445 deletions
-137
View File
@@ -1,137 +0,0 @@
package tests
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/versenilvis/iris/ai"
"github.com/versenilvis/iris/config"
)
func TestCleanSuggestion(t *testing.T) {
tests := []struct {
input string
expected string
}{
{" docker run -d nginx ", "docker run -d nginx"},
{"```bash\ngit status\n```", "git status"},
{"```\nls -la\n```", "ls -la"},
{"`npm run dev`", "npm run dev"},
{"\"docker ps\"", "docker ps"},
{"'git diff'", "git diff"},
{"\"git commit -m 'hello'\"", "\"git commit -m 'hello'\""},
}
for _, tt := range tests {
got := ai.CleanSuggestion(tt.input)
if got != tt.expected {
t.Errorf("CleanSuggestion(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}
func TestOpenAIClient_Suggest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected post method, got %s", r.Method)
}
if r.Header.Get("Authorization") != "Bearer test-secret-key" {
t.Errorf("expected bearer token, got %s", r.Header.Get("Authorization"))
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected application/json, got %s", r.Header.Get("Content-Type"))
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
var reqMap map[string]any
if err := json.Unmarshal(body, &reqMap); err != nil {
t.Fatalf("failed to parse request json: %v", err)
}
if reqMap["model"] != "test-model-32b" {
t.Errorf("expected model test-model-32b, got %v", reqMap["model"])
}
if reqMap["temperature"] != 0.5 {
t.Errorf("expected extra temperature 0.5, got %v", reqMap["temperature"])
}
res := map[string]any{
"choices": []map[string]any{
{"message": map[string]any{"role": "assistant", "content": "```bash\nkubectl get pods -n kube-system\n```"}},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(res)
}))
defer server.Close()
cfg := config.ProviderConfig{
InheritedFrom: "openai",
Endpoint: server.URL,
APIKey: "test-secret-key",
Model: "test-model-32b",
TimeoutMS: 1000,
ExtraRequestBody: map[string]any{
"temperature": 0.5,
},
}
client, err := ai.NewClient(cfg)
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
ctx := context.Background()
env := ai.EnvSnapshot{Cwd: "/home/user", LastCmd: "kubectl get", LastExitCode: 0}
sugg, err := client.Suggest(ctx, "kubectl get p", env, "")
if err != nil {
t.Fatalf("suggest failed: %v", err)
}
if sugg == nil {
t.Fatalf("expected suggestion, got nil")
}
if sugg.Cmd != "kubectl get pods -n kube-system" {
t.Errorf("expected cleaned cmd, got %q", sugg.Cmd)
}
if sugg.Confidence != 85 {
t.Errorf("expected confidence 85, got %d", sugg.Confidence)
}
}
func TestOpenAIClient_TimeoutAndCancel(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := config.ProviderConfig{
InheritedFrom: "openai",
Endpoint: server.URL,
TimeoutMS: 50,
}
client := ai.NewOpenAIClient(cfg)
ctx := context.Background()
env := ai.EnvSnapshot{}
_, err := client.Suggest(ctx, "sleep", env, "")
if err == nil {
t.Errorf("expected timeout error, got nil")
}
ctxCancel, cancel := context.WithCancel(context.Background())
cancel()
_, err = client.Suggest(ctxCancel, "sleep", env, "")
if err == nil {
t.Errorf("expected context canceled error, got nil")
}
}
-163
View File
@@ -1,163 +0,0 @@
package tests
import (
"context"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/versenilvis/iris/ai"
"github.com/versenilvis/iris/spec"
)
type mockProvider struct {
name string
matchPref string
gatherRet string
calls int32
}
func (m *mockProvider) Name() string {
return m.name
}
func (m *mockProvider) Matches(buf string) bool {
return len(buf) >= len(m.matchPref) && buf[:len(m.matchPref)] == m.matchPref
}
func (m *mockProvider) Gather(ctx context.Context) (string, error) {
atomic.AddInt32(&m.calls, 1)
return m.gatherRet, nil
}
func TestProviderCache_TTL(t *testing.T) {
cache := ai.NewProviderCache(50 * time.Millisecond)
provider := &mockProvider{
name: "test-prov",
matchPref: "test",
gatherRet: "cached-data",
}
ctx := context.Background()
// call 1 -> should gather
res1 := cache.GetOrGather(ctx, provider)
if res1 != "cached-data" || atomic.LoadInt32(&provider.calls) != 1 {
t.Fatalf("expected gather call 1, got res: %q, calls: %d", res1, provider.calls)
}
// call 2 immediately -> should hit cache
res2 := cache.GetOrGather(ctx, provider)
if res2 != "cached-data" || atomic.LoadInt32(&provider.calls) != 1 {
t.Fatalf("expected cache hit (calls stay 1), got calls: %d", provider.calls)
}
// wait for ttl to expire
time.Sleep(60 * time.Millisecond)
// call 3 after ttl -> should gather again
res3 := cache.GetOrGather(ctx, provider)
if res3 != "cached-data" || atomic.LoadInt32(&provider.calls) != 2 {
t.Fatalf("expected gather call 2 after ttl, got calls: %d", provider.calls)
}
}
func TestAIEngine_DynamicContext(t *testing.T) {
provider := &mockProvider{
name: "docker-mock",
matchPref: "docker exec",
gatherRet: "test-container\tnginx",
}
engine := ai.NewAIEngine(func(ctx context.Context, buf string, env ai.EnvSnapshot, dynamicCtx string) (*spec.Suggestion, error) {
if dynamicCtx != "test-container\tnginx" {
t.Fatalf("expected dynamicCtx to be passed to handler, got: %q", dynamicCtx)
}
return &spec.Suggestion{Cmd: "docker exec -it test-container bash", Confidence: 85}, nil
})
engine.RegisterProvider(provider)
ctx := context.Background()
sugg, err := engine.Suggest(ctx, "docker exec ", ai.EnvSnapshot{}, "")
if err != nil || sugg == nil {
t.Fatalf("expected suggestion, got err: %v, sugg: %+v", err, sugg)
}
if sugg.Cmd != "docker exec -it test-container bash" {
t.Fatalf("unexpected cmd: %q", sugg.Cmd)
}
}
// Verify that cache evicts expired entries and resets when exceeding 50 items to prevent unbounded memory growth
func TestProviderCache_Eviction(t *testing.T) {
cache := ai.NewProviderCache(10 * time.Millisecond)
ctx := context.Background()
for i := 0; i < 55; i++ {
p := &mockProvider{
name: fmt.Sprintf("prov-%d", i),
matchPref: "test",
gatherRet: "data",
}
cache.GetOrGather(ctx, p)
}
time.Sleep(20 * time.Millisecond)
pNext := &mockProvider{
name: "prov-next",
matchPref: "test",
gatherRet: "data",
}
cache.GetOrGather(ctx, pNext)
}
// Verify that CommandContextProvider caps gathered output to 1000 characters to protect token budget
func TestCommandContextProvider_Truncation(t *testing.T) {
provider := &ai.CommandContextProvider{
NameStr: "test-trunc",
Prefixes: []string{"echo"},
GatherCmd: []string{"go", "env"},
Label: "GoEnv",
}
ctx := context.Background()
res, err := provider.Gather(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(res, "GoEnv:\n") {
t.Fatalf("expected label prefix, got: %q", res)
}
if len(res) > 1100 {
t.Fatalf("expected gathered output to be truncated around 1000 characters, got len: %d", len(res))
}
}
// Verify that concurrent provider registration and context gathering do not cause data races
func TestAIEngine_ConcurrentRegistrationAndGather(t *testing.T) {
engine := ai.NewAIEngine(nil)
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
p := &mockProvider{
name: fmt.Sprintf("conc-prov-%d", idx),
matchPref: "docker",
gatherRet: "conc-data",
}
engine.RegisterProvider(p)
}(i)
wg.Add(1)
go func() {
defer wg.Done()
engine.GatherDynamicContext(ctx, "docker ps", "/tmp")
}()
}
wg.Wait()
}
-122
View File
@@ -1,122 +0,0 @@
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)
}
}
-147
View File
@@ -1,147 +0,0 @@
package tests
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/versenilvis/iris/ai"
"github.com/versenilvis/iris/config"
"github.com/versenilvis/iris/spec"
)
func TestEnvSnapshot_Hash(t *testing.T) {
snap1 := ai.EnvSnapshot{
Cwd: "/home/user",
LastCmd: "ls -l",
LastExitCode: 0,
GitStatus: "clean",
DirSignature: "sig1",
}
hash1 := snap1.Hash()
if len(hash1) != 16 {
t.Fatalf("expected 16 hex chars, got len %d: %q", len(hash1), hash1)
}
snap2 := snap1
snap2.LastCmd = "pwd"
if snap1.Hash() == snap2.Hash() {
t.Fatalf("expected different hash when field changes")
}
// Verify that fields containing delimiter characters do not collide
snapA := ai.EnvSnapshot{Cwd: "/home/user", LastCmd: "foo|bar"}
snapB := ai.EnvSnapshot{Cwd: "/home/user|foo", LastCmd: "bar"}
if snapA.Hash() == snapB.Hash() {
t.Fatalf("expected different hash for distinct snapshots containing delimiter characters")
}
}
func TestAIEngine_Suggest_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
res := map[string]any{
"choices": []map[string]any{
{"message": map[string]string{"role": "assistant", "content": "git commit -m \"feat: update main.go\""}},
},
}
_ = json.NewEncoder(w).Encode(res)
}))
defer server.Close()
cfg := config.Get()
origAI := cfg.AI
defer func() { cfg.AI = origAI }()
cfg.AI.Enabled = true
cfg.AI.Provider = "test-provider"
cfg.AI.Providers = map[string]config.ProviderConfig{
"test-provider": {
InheritedFrom: "openai",
Endpoint: server.URL,
Model: "test-model",
},
}
engine := ai.NewAIEngine(nil)
ctx := context.Background()
snap := ai.EnvSnapshot{GitStatus: "modified main.go"}
sugg, err := engine.Suggest(ctx, "git commit -m \"", snap, "")
if err != nil {
t.Fatalf("expected success, got err: %v", err)
}
if sugg == nil || sugg.Confidence != 85 || sugg.Source != string(ai.SourceAI) {
t.Fatalf("unexpected suggestion: %+v", sugg)
}
}
func TestAIEngine_Suggest_Cancel(t *testing.T) {
engine := ai.NewAIEngine(func(ctx context.Context, buf string, env ai.EnvSnapshot, dynamicCtx string) (*spec.Suggestion, error) {
time.Sleep(50 * time.Millisecond)
return &spec.Suggestion{Cmd: "test", Confidence: 90}, nil
})
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately before calling
sugg, err := engine.Suggest(ctx, "git commit -m \"", ai.EnvSnapshot{}, "")
if err == nil || sugg != nil {
t.Fatalf("expected cancellation error and nil suggestion, got sugg: %+v, err: %v", sugg, err)
}
}
func TestShouldOverwrite(t *testing.T) {
newSugg := &spec.Suggestion{
Cmd: "git commit -m \"feat: new feature\"",
Confidence: 85,
Source: string(ai.SourceAI),
}
// case 1: exact match and higher confidence
if !ai.ShouldOverwrite("git commit -m \"", "git commit -m \"f", newSugg, 70) {
t.Fatalf("expected true for prefix match with higher confidence")
}
// case 2: user backspaced / typed different prefix
if ai.ShouldOverwrite("git commit -m \"", "git co", newSugg, 70) {
t.Fatalf("expected false when current buf does not match original prefix")
}
// case 3: new suggestion does not match what user typed
if ai.ShouldOverwrite("git commit -m \"", "git commit -m \"fix", newSugg, 70) {
t.Fatalf("expected false when suggestion cmd does not match current buf")
}
// case 4: lower or equal confidence
if ai.ShouldOverwrite("git commit -m \"", "git commit -m \"", newSugg, 90) {
t.Fatalf("expected false when new confidence is lower")
}
}
// Verify that caller mutations on returned fresh suggestions do not corrupt the internal engine cache
func TestAIEngine_Suggest_Immutability(t *testing.T) {
engine := ai.NewAIEngine(func(ctx context.Context, buf string, env ai.EnvSnapshot, dynamicCtx string) (*spec.Suggestion, error) {
return &spec.Suggestion{Cmd: "echo original", Confidence: 90}, nil
})
ctx := context.Background()
sugg1, err := engine.Suggest(ctx, "echo ", ai.EnvSnapshot{}, "")
if err != nil || sugg1 == nil {
t.Fatalf("expected suggestion, got err: %v, sugg: %v", err, sugg1)
}
// Mutate returned suggestion
sugg1.Cmd = "echo corrupted"
// Fetch from cache via prefix match
sugg2, err := engine.Suggest(ctx, "echo ", ai.EnvSnapshot{}, "")
if err != nil || sugg2 == nil {
t.Fatalf("expected cached suggestion, got err: %v, sugg: %v", err, sugg2)
}
if sugg2.Cmd != "echo original" {
t.Fatalf("expected cache to remain 'echo original', got corrupted cmd: %q", sugg2.Cmd)
}
}
@@ -4,12 +4,10 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
_ "github.com/versenilvis/iris/commands"
"github.com/versenilvis/iris/spec"
"github.com/versenilvis/iris/commands/js"
"github.com/versenilvis/iris/spec"
)
func TestNpmScriptGenerator(t *testing.T) {
@@ -102,93 +100,3 @@ func TestNpmScriptGenerator(t *testing.T) {
}
})
}
func TestSshHostGenerator(t *testing.T) {
tmp := t.TempDir()
sshDir := filepath.Join(tmp, ".ssh")
_ = os.MkdirAll(sshDir, 0700)
configContent := `
Host prod-server
HostName 10.0.0.1
User deploy
Host staging bastion
HostName staging.example.com
User ubuntu
Host *.internal
User admin
Host !forbidden wildcard-test
HostName test.internal
`
_ = os.WriteFile(filepath.Join(sshDir, "config"), []byte(configContent), 0600)
// temporarily replace home dir lookup by using a mock path
// we call the generator directly with a custom home dir
results := sshHostGeneratorFromPath(filepath.Join(sshDir, "config"))
found := make(map[string]bool)
for _, r := range results {
found[r.Cmd] = true
}
if !found["prod-server"] {
t.Error("expected prod-server in suggestions")
}
if !found["staging"] {
t.Error("expected staging in suggestions")
}
if !found["bastion"] {
t.Error("expected bastion in suggestions")
}
// wildcards should be excluded
if found["*.internal"] {
t.Error("wildcard *.internal should not be suggested")
}
if found["!forbidden"] {
t.Error("negated host !forbidden should not be suggested")
}
}
// sshHostGeneratorFromPath is a helper that reads a specific ssh config path
func sshHostGeneratorFromPath(configPath string) []spec.Suggestion {
import_bufio := func() {
// using bufio in the same style as ssh.go
}
_ = import_bufio
f, err := os.Open(configPath)
if err != nil {
return nil
}
defer func() { _ = f.Close() }()
seen := make(map[string]bool)
var results []spec.Suggestion
scanner := strings.NewReader("")
_ = scanner
data, _ := os.ReadFile(configPath)
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(strings.ToLower(line), "host ") {
continue
}
parts := strings.Fields(line)
for _, host := range parts[1:] {
if strings.ContainsAny(host, "*?!") {
continue
}
if seen[host] {
continue
}
seen[host] = true
results = append(results, spec.Suggestion{Cmd: host, Desc: "ssh host"})
}
}
return results
}
+88
View File
@@ -0,0 +1,88 @@
package tests
import (
"bufio"
"os"
"path/filepath"
"strings"
"testing"
"github.com/versenilvis/iris/spec"
)
func TestSshHostGenerator(t *testing.T) {
tmp := t.TempDir()
sshDir := filepath.Join(tmp, ".ssh")
_ = os.MkdirAll(sshDir, 0700)
configContent := `
Host prod-server
HostName 10.0.0.1
User deploy
Host staging bastion
HostName staging.example.com
User ubuntu
Host *.internal
User admin
Host !forbidden wildcard-test
HostName test.internal
`
_ = os.WriteFile(filepath.Join(sshDir, "config"), []byte(configContent), 0600)
results := sshHostGeneratorFromPath(filepath.Join(sshDir, "config"))
found := make(map[string]bool)
for _, r := range results {
found[r.Cmd] = true
}
if !found["prod-server"] {
t.Error("expected prod-server in suggestions")
}
if !found["staging"] {
t.Error("expected staging in suggestions")
}
if !found["bastion"] {
t.Error("expected bastion in suggestions")
}
if !found["wildcard-test"] {
t.Error("expected wildcard-test in suggestions")
}
if found["*.internal"] || found["!forbidden"] {
t.Error("wildcards and negations should be ignored")
}
}
// sshHostGeneratorFromPath is a helper that reads a specific ssh config path
func sshHostGeneratorFromPath(configPath string) []spec.Suggestion {
f, err := os.Open(configPath)
if err != nil {
return nil
}
defer func() { _ = f.Close() }()
seen := make(map[string]bool)
var results []spec.Suggestion
scanner := bufio.NewScanner(f)
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
if len(parts) < 2 || !strings.EqualFold(parts[0], "host") {
continue
}
for _, host := range parts[1:] {
if strings.ContainsAny(host, "*?!") {
continue
}
if seen[host] {
continue
}
seen[host] = true
results = append(results, spec.Suggestion{Cmd: host, Desc: "ssh host"})
}
}
return results
}
@@ -18,10 +18,7 @@ func TestZoxideGenerator(t *testing.T) {
script := "#!/bin/sh\necho \"/home/verse/project1\n/home/verse/docs\n/home/verse/dev/iris\""
_ = os.WriteFile(mockZoxide, []byte(script), 0755)
// Add tmp to PATH
oldPath := os.Getenv("PATH")
_ = os.Setenv("PATH", tmp+string(os.PathListSeparator)+oldPath)
defer func() { _ = os.Setenv("PATH", oldPath) }()
t.Setenv("PATH", tmp+string(os.PathListSeparator)+os.Getenv("PATH"))
gen := fs.ZoxideGenerator()
-304
View File
@@ -1,304 +0,0 @@
package tests
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
"github.com/versenilvis/iris/config"
"github.com/versenilvis/iris/root"
)
func TestDefaultConfigAndState(t *testing.T) {
cfg := config.DefaultConfig()
if cfg.Core.Version != 1 {
t.Errorf("expected version 1, got %d", cfg.Core.Version)
}
if cfg.UI.MaxSuggestions != 100 {
t.Errorf("expected suggestions 100, got %d", cfg.UI.MaxSuggestions)
}
if cfg.AI.Enabled {
t.Errorf("expected AI to be disabled by default")
}
if cfg.AI.Provider != "" {
t.Errorf("expected default provider to be empty, got %q", cfg.AI.Provider)
}
if cfg.AI.Providers != nil {
t.Errorf("expected default providers map to be nil, got %v", cfg.AI.Providers)
}
// test manual provider registration
cfg.AI.Provider = "custom"
cfg.AI.Providers = map[string]config.ProviderConfig{
"custom": {
InheritedFrom: "openai",
Endpoint: "https://custom-api.com/v1",
APIKey: "test-key",
Model: "test-model",
TimeoutMS: 1000,
},
}
p, ok := cfg.AI.GetActiveProvider()
if !ok {
t.Fatalf("expected custom provider to exist")
}
if p.InheritedFrom != "openai" {
t.Errorf("expected inherited_from openai, got %q", p.InheritedFrom)
}
if p.GetAPIKey() != "test-key" {
t.Errorf("expected api key test-key, got %q", p.GetAPIKey())
}
if cfg.AI.SuggestOnEmpty.DebounceMS != 800 {
t.Errorf("expected debounce 800, got %d", cfg.AI.SuggestOnEmpty.DebounceMS)
}
if cfg.AI.SuggestOnEmpty.MinIntervalMS != 5000 {
t.Errorf("expected min interval 5000, got %d", cfg.AI.SuggestOnEmpty.MinIntervalMS)
}
state := config.DefaultState()
if state.LastMode != "spec" {
t.Errorf("expected last mode spec, got %q", state.LastMode)
}
}
func TestCustomDuration(t *testing.T) {
var dur config.Duration
err := dur.UnmarshalText([]byte("6h"))
if err != nil {
t.Fatalf("unexpected error unmarshalling duration: %v", err)
}
if time.Duration(dur) != 6*time.Hour {
t.Errorf("expected 6 hours, got %v", time.Duration(dur))
}
b, err := dur.MarshalText()
if err != nil {
t.Fatalf("unexpected error marshaling duration: %v", err)
}
if string(b) != "6h0m0s" {
t.Errorf("expected 6h0m0s, got %q", string(b))
}
err = dur.UnmarshalText([]byte("invalid"))
if err == nil {
t.Errorf("expected error for invalid duration")
}
}
func TestValidationAndEnvironmentOverrides(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "iris-config-env-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
defer func() { _ = os.Unsetenv("XDG_CONFIG_HOME") }()
configDir := filepath.Join(tmpDir, "iris")
if mkErr := os.MkdirAll(configDir, 0755); mkErr != nil {
t.Fatalf("failed to create config dir: %v", mkErr)
}
configPath := filepath.Join(configDir, "config.toml")
tomlContent := `
[ai]
enabled = true
provider = "groq"
[ai.providers.groq]
inherited_from = "openai"
endpoint = "https://api.groq.com/openai/v1"
api_key_env = "GROQ_API_KEY"
model = "qwen-2.5-coder-32b"
`
if wrErr := os.WriteFile(configPath, []byte(tomlContent), 0644); wrErr != nil {
t.Fatalf("failed to write config file: %v", wrErr)
}
_ = os.Setenv("IRIS_CORE_DEBUG", "true")
_ = os.Setenv("IRIS_CORE_SHELL", "fish")
_ = os.Setenv("IRIS_CORE_MODE", "history")
_ = os.Setenv("IRIS_UI_GHOST_TEXT", "false")
_ = os.Setenv("IRIS_UI_MAX_SUGGESTIONS", "250")
_ = os.Setenv("IRIS_UI_MAX_HEIGHT", "25")
_ = os.Setenv("IRIS_UPDATER_CHANNEL", "nightly")
_ = os.Setenv("IRIS_UPDATER_INTERVAL", "12h")
_ = os.Setenv("IRIS_UPDATER_CHECK_ON_STARTUP", "false")
_ = os.Setenv("IRIS_AI_PROVIDER", "ollama")
_ = os.Setenv("GROQ_API_KEY", "gsk_test_123")
defer func() {
_ = os.Unsetenv("IRIS_CORE_DEBUG")
_ = os.Unsetenv("IRIS_CORE_SHELL")
_ = os.Unsetenv("IRIS_CORE_MODE")
_ = os.Unsetenv("IRIS_UI_GHOST_TEXT")
_ = os.Unsetenv("IRIS_UI_MAX_SUGGESTIONS")
_ = os.Unsetenv("IRIS_UI_MAX_HEIGHT")
_ = os.Unsetenv("IRIS_UPDATER_CHANNEL")
_ = os.Unsetenv("IRIS_UPDATER_INTERVAL")
_ = os.Unsetenv("IRIS_UPDATER_CHECK_ON_STARTUP")
_ = os.Unsetenv("IRIS_AI_PROVIDER")
_ = os.Unsetenv("GROQ_API_KEY")
}()
cfg, err := config.Load()
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
if !cfg.Core.Debug {
t.Errorf("expected debug to be true")
}
if cfg.Core.Shell != "fish" {
t.Errorf("expected shell fish, got %q", cfg.Core.Shell)
}
if cfg.Core.Mode != "history" {
t.Errorf("expected mode history, got %q", cfg.Core.Mode)
}
if cfg.UI.GhostText {
t.Errorf("expected ghost text to be false")
}
if cfg.UI.MaxSuggestions != 250 {
t.Errorf("expected max suggestions 250, got %d", cfg.UI.MaxSuggestions)
}
if cfg.UI.MaxHeight != 25 {
t.Errorf("expected max height 25, got %d", cfg.UI.MaxHeight)
}
if cfg.Updater.Channel != "nightly" {
t.Errorf("expected channel nightly, got %q", cfg.Updater.Channel)
}
if time.Duration(cfg.Updater.CheckInterval) != 12*time.Hour {
t.Errorf("expected 12h, got %v", time.Duration(cfg.Updater.CheckInterval))
}
if cfg.Updater.CheckOnStartup {
t.Errorf("expected check on startup to be false")
}
if cfg.AI.Provider != "ollama" {
t.Errorf("expected provider ollama from env, got %q", cfg.AI.Provider)
}
groqCfg := cfg.AI.Providers["groq"]
if groqCfg.GetAPIKey() != "gsk_test_123" {
t.Errorf("expected groq api key gsk_test_123 from env, got %q", groqCfg.GetAPIKey())
}
_ = os.Setenv("IRIS_CORE_MODE", "invalid")
_, err = config.Load()
if err == nil {
t.Errorf("expected validation error for invalid mode in env")
}
}
func TestLoadSave(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "iris-config-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
defer func() { _ = os.Unsetenv("XDG_CONFIG_HOME") }()
cfg, err := config.Load()
if err != nil {
t.Fatalf("failed to load: %v", err)
}
cfg.Core.Shell = "zsh"
cfg.UI.MaxHeight = 20
err = config.Save(cfg)
if err != nil {
t.Fatalf("failed to save: %v", err)
}
loaded, err := config.Load()
if err != nil {
t.Fatalf("failed to load after save: %v", err)
}
if loaded.Core.Shell != "zsh" {
t.Errorf("expected loaded shell to be zsh, got %q", loaded.Core.Shell)
}
if loaded.UI.MaxHeight != 20 {
t.Errorf("expected loaded height to be 20, got %d", loaded.UI.MaxHeight)
}
}
func TestMigration(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "iris-migrate-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
_ = os.Setenv("HOME", tmpDir)
_ = os.Setenv("XDG_DATA_HOME", filepath.Join(tmpDir, ".local", "share"))
defer func() {
_ = os.Unsetenv("HOME")
_ = os.Unsetenv("XDG_DATA_HOME")
}()
legacyDir := filepath.Join(tmpDir, ".iris")
if errMkdir := os.MkdirAll(legacyDir, 0755); errMkdir != nil {
t.Fatalf("failed to create legacy dir: %v", errMkdir)
}
legacyStateJson := `{"mode": "history"}`
_ = os.WriteFile(filepath.Join(legacyDir, "state.json"), []byte(legacyStateJson), 0644)
legacyUpdateJson := `{"seen_version": "v1.2.3", "last_check": 1234567890}`
_ = os.WriteFile(filepath.Join(legacyDir, "update_state.json"), []byte(legacyUpdateJson), 0644)
err = config.MigrateFromLegacyJSON()
if err != nil {
t.Fatalf("migration failed: %v", err)
}
state := config.LoadState()
if state.LastMode != "history" {
t.Errorf("expected migrated last mode 'history', got %q", state.LastMode)
}
if state.Updater.SeenVersion != "v1.2.3" {
t.Errorf("expected migrated seen version 'v1.2.3', got %q", state.Updater.SeenVersion)
}
if state.Updater.LastCheckTime.Unix() != 1234567890 {
t.Errorf("expected migrated check time 1234567890, got %v", state.Updater.LastCheckTime.Unix())
}
if _, err := os.Stat(filepath.Join(legacyDir, "state.json.bak")); err != nil {
t.Errorf("expected backup file state.json.bak to exist")
}
if _, err := os.Stat(filepath.Join(legacyDir, "update_state.json.bak")); err != nil {
t.Errorf("expected backup file update_state.json.bak to exist")
}
}
func TestConfigCommands(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "iris-config-cmd-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
origConfigHome := os.Getenv("XDG_CONFIG_HOME")
defer func() {
_ = os.Setenv("XDG_CONFIG_HOME", origConfigHome)
}()
_ = os.Setenv("XDG_CONFIG_HOME", tmpDir)
root.ConfigInitCmd.Run(root.ConfigInitCmd, []string{})
configPath := filepath.Join(tmpDir, "iris", "config.toml")
if _, err := os.Stat(configPath); err != nil {
t.Errorf("expected config file to be created at %s, but it was not", configPath)
}
buf := new(bytes.Buffer)
root.ConfigShowCmd.SetOut(buf)
root.ConfigShowCmd.Run(root.ConfigShowCmd, []string{})
if buf.Len() == 0 {
t.Errorf("expected show command to output configuration")
}
}
-103
View File
@@ -1,103 +0,0 @@
package tests
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/versenilvis/iris/root"
)
func TestWriteCrashLog(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "iris-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
origHome := os.Getenv("HOME")
origCache := os.Getenv("XDG_CACHE_HOME")
defer func() {
_ = os.Setenv("HOME", origHome)
_ = os.Setenv("XDG_CACHE_HOME", origCache)
}()
_ = os.Setenv("HOME", tmpDir)
_ = os.Setenv("XDG_CACHE_HOME", filepath.Join(tmpDir, ".cache"))
testErr := "test panic message"
root.WriteCrashLog(testErr)
dir := filepath.Join(tmpDir, ".cache", "iris", "crashes")
files, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("failed to read crashes dir: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected exactly 1 crash log file, got %d", len(files))
}
logPath := filepath.Join(dir, files[0].Name())
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("failed to read crash log: %v", err)
}
content := string(data)
if !strings.Contains(content, "=== IRIS CRASH ") {
t.Errorf("expected header in log, got: %s", content)
}
if !strings.Contains(content, "panic: test panic message") {
t.Errorf("expected panic message in log, got: %s", content)
}
if !strings.Contains(content, "version:") {
t.Errorf("expected version in log, got: %s", content)
}
}
func TestCrashLogCommand(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "iris-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
origHome := os.Getenv("HOME")
origCache := os.Getenv("XDG_CACHE_HOME")
defer func() {
_ = os.Setenv("HOME", origHome)
_ = os.Setenv("XDG_CACHE_HOME", origCache)
}()
_ = os.Setenv("HOME", tmpDir)
_ = os.Setenv("XDG_CACHE_HOME", filepath.Join(tmpDir, ".cache"))
var buf bytes.Buffer
root.CrashCmd.SetOut(&buf)
root.CrashCmd.SetArgs([]string{})
root.ClearLog = false
root.CrashCmd.Run(root.CrashCmd, []string{})
if !strings.Contains(buf.String(), "no crash log found") {
t.Errorf("expected 'no crash log found', got: %q", buf.String())
}
root.WriteCrashLog("mock error")
buf.Reset()
root.CrashCmd.Run(root.CrashCmd, []string{})
if !strings.Contains(buf.String(), "crash_") || !strings.Contains(buf.String(), ".log") {
t.Errorf("expected crash log path, got: %q", buf.String())
}
buf.Reset()
root.ClearLog = true
root.CrashCmd.Run(root.CrashCmd, []string{})
if !strings.Contains(buf.String(), "crash log cleared") {
t.Errorf("expected 'crash log cleared', got: %q", buf.String())
}
dir := filepath.Join(tmpDir, ".cache", "iris", "crashes")
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("expected crashes directory to be deleted, but it exists")
}
}
-50
View File
@@ -1,50 +0,0 @@
package tests
import (
"testing"
"github.com/versenilvis/iris/integration"
)
func TestComputeCursorCol(t *testing.T) {
tests := []struct {
name string
data []byte
want int
}{
{
name: "Simple prompt",
data: []byte("λ "),
want: 2,
},
{
name: "ANSI color prompt",
data: []byte("\033[32mλ \033[0m"),
want: 2,
},
{
name: "Carriage return and move right",
data: []byte("λ \033[140G...22 chars right prompt...\r\033[2C"),
want: 2,
},
{
name: "OSC sequence before prompt",
data: []byte("\033]0;iris on fix/menu-debouncing\007λ "),
want: 2,
},
{
name: "CSI Horizontal Absolute",
data: []byte("abc\033[10Gde"),
want: 11,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := integration.ComputeCursorCol(tt.data)
if got != tt.want {
t.Errorf("ComputeCursorCol(%q) = %d, want %d", tt.data, got, tt.want)
}
})
}
}
-129
View File
@@ -1,129 +0,0 @@
package tests
import (
"strings"
"testing"
"github.com/versenilvis/iris/integration"
"github.com/versenilvis/iris/spec"
)
func TestRenderGhostText_CursorAtEnd(t *testing.T) {
o := integration.NewOverlay()
items := []spec.Suggestion{
{Cmd: "git checkout -b feature"},
}
o.UpdateItems(items)
// case 1: cursor at end of buffer -> should render ghost text suffix
out := o.RenderGhostText("git check", false, true)
if !strings.Contains(out, "out -b feature") {
t.Fatalf("Expected ghost text suffix 'out -b feature', got: %q", out)
}
if o.LastGhostLen == 0 {
t.Fatalf("Expected LastGhostLen > 0, got %d", o.LastGhostLen)
}
// case 2: cursor moved left (cursorAtEnd == false) -> should clear ghost text
outClear := o.RenderGhostText("git check", false, false)
if strings.Contains(outClear, "out -b feature") {
t.Fatalf("Expected ghost text to be hidden/cleared when cursor moved left, got: %q", outClear)
}
if o.LastGhostLen != 0 {
t.Fatalf("Expected LastGhostLen == 0 after clearing, got %d", o.LastGhostLen)
}
}
func TestGetGhostText(t *testing.T) {
o := integration.NewOverlay()
items := []spec.Suggestion{
{Cmd: "docker exec -it my-container bash"},
}
o.UpdateItems(items)
// case 1: cursor at end
ghost := o.GetGhostText("docker e", true)
expected := "xec -it my-container bash"
if ghost != expected {
t.Fatalf("Expected %q, got %q", expected, ghost)
}
// case 2: cursor not at end (moved left)
ghostLeft := o.GetGhostText("docker e", false)
if ghostLeft != "" {
t.Fatalf("Expected empty string when cursor not at end, got %q", ghostLeft)
}
// case 3: user navigated menu with Up/Down arrow -> should sync with highlighted item
o.SetUserNavigated(true)
ghostNav := o.GetGhostText("docker e", true)
if ghostNav != expected {
t.Fatalf("Expected %q when user navigated menu, got %q", expected, ghostNav)
}
}
func TestGhostText_MenuSync(t *testing.T) {
o := integration.NewOverlay()
items := []spec.Suggestion{
{Cmd: "git checkout -b first"},
{Cmd: "git checkout master"},
}
o.UpdateItems(items)
// default item 0
ghost0 := o.GetGhostText("git check", true)
if ghost0 != "out -b first" {
t.Fatalf("Expected 'out -b first', got %q", ghost0)
}
// move cursor down to item 1
o.MoveCursor("down")
ghost1 := o.GetGhostText("git check", true)
if ghost1 != "out master" {
t.Fatalf("Expected 'out master', got %q", ghost1)
}
out := o.RenderGhostText("git check", true, true)
if !strings.Contains(out, "out master") {
t.Fatalf("Expected RenderGhostText to render 'out master', got %q", out)
}
}
func TestGhostText_Truncation(t *testing.T) {
o := integration.NewOverlay()
longCmd := "git commit -m '" + strings.Repeat("a", 150) + "'"
items := []spec.Suggestion{
{Cmd: longCmd},
}
o.UpdateItems(items)
o.SetPromptLen(10)
// typed query length 105 -> total cursor col = 115, default width = 120 -> available cols = 5
typedQuery := "git commit -m '" + strings.Repeat("a", 90)
out := o.RenderGhostText(typedQuery, false, true)
if !strings.Contains(out, "…") {
t.Fatalf("Expected truncated ghost text with '…', got %q", out)
}
}
func TestHideMenu_PreservesTypedQueryForAI(t *testing.T) {
o := integration.NewOverlay()
o.HideMenu("git commit")
if o.GetTypedQuery() != "git commit" {
t.Fatalf("Expected TypedQuery to be preserved as 'git commit', got %q", o.GetTypedQuery())
}
aiSugg := spec.Suggestion{
Cmd: "git commit -m 'fix: test'",
Desc: "AI suggestion",
Source: "ai",
Confidence: 85,
}
if !o.InjectAISuggestion(aiSugg) {
t.Fatalf("Expected InjectAISuggestion to succeed after HideMenu")
}
if !o.IsVisible() || len(o.Items) == 0 || o.Items[0].Cmd != aiSugg.Cmd {
t.Fatalf("Expected AI suggestion to be injected into Items[0] and Visible=true")
}
}
+1 -4
View File
@@ -27,10 +27,7 @@ func TestIntegration_ZoxideMultiWord(t *testing.T) {
// Create mock zoxide binary
script := "#!/bin/sh\necho \"" + targetDir + "\""
_ = os.WriteFile(mockZoxide, []byte(script), 0755)
// Add mock bin to PATH
oldPath := os.Getenv("PATH")
_ = os.Setenv("PATH", mockBinDir+string(os.PathListSeparator)+oldPath)
defer func() { _ = os.Setenv("PATH", oldPath) }()
t.Setenv("PATH", mockBinDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Run("z matches multi-word folder without quotes", func(t *testing.T) {
// Simulating user typing "z My Awe"
-91
View File
@@ -1,91 +0,0 @@
package tests
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/versenilvis/iris/logger"
)
func TestLogger(t *testing.T) {
tempDir, err := os.MkdirTemp("", "iris-log-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
logFilePath := filepath.Join(tempDir, "test.log")
// test 1: default init sets level to info
logger.Init(logFilePath, false)
logger.Debugf("this debug msg should not be logged")
logger.Infof("this info msg should be logged")
logger.Close()
data, err := os.ReadFile(logFilePath)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
content := string(data)
if strings.Contains(content, "this debug msg should not be logged") {
t.Errorf("expected debug log to be skipped, got: %s", content)
}
if !strings.Contains(content, "this info msg should be logged") {
t.Errorf("expected info log to be recorded, got: %s", content)
}
if !strings.Contains(content, "[INFO]") {
t.Errorf("expected log to contain INFO tag, got: %s", content)
}
if !strings.Contains(content, "logger_test.go:") {
t.Errorf("expected log to contain caller trace info, got: %s", content)
}
// test 2: override init with debug = true
_ = os.Remove(logFilePath)
logger.Init(logFilePath, true)
logger.Debugf("this debug msg should now be logged")
logger.Close()
data, err = os.ReadFile(logFilePath)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
content = string(data)
if !strings.Contains(content, "this debug msg should now be logged") {
t.Errorf("expected debug log to be recorded, got: %s", content)
}
if !strings.Contains(content, "[DEBUG]") {
t.Errorf("expected log to contain DEBUG tag, got: %s", content)
}
// test 3: test log rotation to .old
// create a large file
largeData := make([]byte, 6*1024*1024)
err = os.WriteFile(logFilePath, largeData, 0644)
if err != nil {
t.Fatalf("failed to write large file: %v", err)
}
logger.Init(logFilePath, false)
logger.Infof("new log after rotation")
logger.Close()
// check if old file exists and is rotated
oldPath := logFilePath + ".old"
if _, err = os.Stat(oldPath); os.IsNotExist(err) {
t.Errorf("expected rotated log file to exist at %s", oldPath)
}
data, err = os.ReadFile(logFilePath)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
content = string(data)
if !strings.Contains(content, "new log after rotation") {
t.Errorf("expected new log to exist in the fresh log file, got: %s", content)
}
}
-50
View File
@@ -1,50 +0,0 @@
package tests
import (
"testing"
"github.com/versenilvis/iris/root"
"github.com/versenilvis/iris/spec"
)
func TestMergeResults(t *testing.T) {
t.Run("Dedup exact match", func(t *testing.T) {
// Mock history items that might conflict with specs
res := root.MergeResults("git", "spec")
seen := make(map[string]bool)
for _, r := range res {
if seen[r.Cmd] {
t.Errorf("Duplicate suggestion found: %q", r.Cmd)
}
seen[r.Cmd] = true
}
})
t.Run("Limit 100", func(t *testing.T) {
res := root.MergeResults("a", "history")
if len(res) > 100 {
t.Errorf("Expected max 100 suggestions, got %d", len(res))
}
})
t.Run("AI Suggestion Promotion", func(t *testing.T) {
aiSugg := &spec.Suggestion{
Cmd: "git commit -m \"fix(auth): login bug\"",
Desc: "AI suggestion",
Source: "ai",
Confidence: 85,
}
root.SetCurrentAISuggestion(aiSugg)
defer root.SetCurrentAISuggestion(nil)
res := root.MergeResults("git c", "history")
if len(res) == 0 {
t.Fatalf("Expected suggestions, got 0")
}
if res[0].Cmd != aiSugg.Cmd {
t.Errorf("Expected AI suggestion at index 0, got %q (confidence %d)", res[0].Cmd, res[0].Confidence)
}
})
}
-77
View File
@@ -1,77 +0,0 @@
package tests
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/versenilvis/iris/config"
"github.com/versenilvis/iris/root"
)
func TestIsNewer(t *testing.T) {
tests := []struct {
current string
latest string
want bool
}{
{"v1.0.0", "v1.0.1", true},
{"v1.0.1", "v1.0.0", false},
{"v1.0.0", "v1.0.0", false},
{"v1.2.3", "v1.2.4", true},
{"v1.2.0", "v1.1.9", false},
{"dev", "v1.0.0", false}, // dev never updates
{"v1.0.0", "dev", false},
{"", "v1.0.0", false},
{"v1.0.0", "v1.1.0-nightly.8cb1f47", false}, // nightly never triggers update
{"v1.1.0-nightly.abc", "v1.2.0", true}, // but if you are on nightly, you can update to stable
}
for _, tt := range tests {
if got := root.IsNewer(tt.current, tt.latest); got != tt.want {
t.Errorf("IsNewer(%q, %q) = %v; want %v", tt.current, tt.latest, got, tt.want)
}
}
}
func TestUpdateState(t *testing.T) {
// Use a temporary directory for the state file
tmpDir, err := os.MkdirTemp("", "iris-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
// Override home dir for testing
homeBackup := os.Getenv("HOME")
err = os.Setenv("HOME", tmpDir)
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.Setenv("HOME", homeBackup) }()
xdgBackup := os.Getenv("XDG_DATA_HOME")
err = os.Setenv("XDG_DATA_HOME", filepath.Join(tmpDir, ".local", "share"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.Setenv("XDG_DATA_HOME", xdgBackup) }()
state := config.LoadState()
state.Updater.SeenVersion = "v1.0.0"
state.Updater.LastCheckTime = time.Unix(123456789, 0)
err = config.SaveState(state)
if err != nil {
t.Fatalf("failed to save state: %v", err)
}
loaded := config.LoadState()
if loaded.Updater.SeenVersion != state.Updater.SeenVersion {
t.Errorf("Expected SeenVersion %q, got %q", state.Updater.SeenVersion, loaded.Updater.SeenVersion)
}
if loaded.Updater.LastCheckTime.Unix() != state.Updater.LastCheckTime.Unix() {
t.Errorf("Expected LastCheck %v, got %v", state.Updater.LastCheckTime, loaded.Updater.LastCheckTime)
}
}
-52
View File
@@ -1,52 +0,0 @@
package tests
import (
"reflect"
"testing"
"github.com/versenilvis/iris/integration/shell"
)
func TestScanPosixAliases(t *testing.T) {
input := `
alias gca='git commit -a'
alias ta="tmux a -t" # this is a comment
# alias hidden="not found"
alias l='ls' ll='ls -l'
`
expected := map[string]string{
"gca": "git commit -a",
"ta": "tmux a -t",
"l": "ls",
"ll": "ls -l",
}
got := shell.ParseAliases(input)
if !reflect.DeepEqual(got, expected) {
t.Errorf("ScanPosixAliases() = %v; want %v", got, expected)
}
}
func TestSplitAliasTokens(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{"Single", "a='b'", []string{"a='b'"}},
{"Multi", "a='b' c=\"d\"", []string{"a='b'", "c=\"d\""}},
{"With Space", "ta='tmux a -t' l='ls -l'", []string{"ta='tmux a -t'", "l='ls -l'"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shell.SplitAliasTokens(tt.input)
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("SplitAliasTokens(%q) = %v; want %v", tt.input, got, tt.expected)
}
})
}
}
-96
View File
@@ -1,96 +0,0 @@
package tests
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/versenilvis/iris/spec"
)
func TestFileGenerator(t *testing.T) {
// Setup mock files
tmp := t.TempDir()
_ = os.MkdirAll(filepath.Join(tmp, "src"), 0755)
_ = os.WriteFile(filepath.Join(tmp, "main.go"), []byte(""), 0644)
_ = os.WriteFile(filepath.Join(tmp, "README.md"), []byte(""), 0644)
_ = os.WriteFile(filepath.Join(tmp, ".hidden"), []byte(""), 0644)
_ = os.WriteFile(filepath.Join(tmp, "src/utils.go"), []byte(""), 0644)
oldWd, _ := os.Getwd()
_ = os.Chdir(tmp)
defer func() { _ = os.Chdir(oldWd) }()
t.Run("dirOnly shows only dirs", func(t *testing.T) {
gen := spec.FileGenerator("/")
results := gen([]string{"cd", ""}, "cd ", "")
for _, r := range results {
if !strings.HasSuffix(r.Cmd, "/") {
t.Errorf("Expected only directories, got %q", r.Cmd)
}
}
})
t.Run("Filter extension", func(t *testing.T) {
gen := spec.FileGenerator(".go")
results := gen([]string{"ls", ""}, "ls ", "")
foundMain := false
for _, r := range results {
if r.Cmd == "main.go" {
foundMain = true
}
if r.Cmd == "README.md" {
t.Errorf("Did not expect README.md when filtering for .go")
}
}
if !foundMain {
t.Errorf("FileGenerator(\".go\") did not suggest main.go")
}
})
t.Run("Nested path", func(t *testing.T) {
gen := spec.FileGenerator()
results := gen([]string{"ls", "src/u"}, "ls src/u", "src/u")
foundUtils := false
for _, r := range results {
if r.Cmd == "src/utils.go" {
foundUtils = true
}
}
if !foundUtils {
t.Errorf("Did not find src/utils.go for nested path src/u")
}
})
t.Run("Deep scan 1 level", func(t *testing.T) {
gen := spec.FileGenerator()
results := gen([]string{"ls", "src/"}, "ls src/", "src/")
foundUtils := false
for _, r := range results {
if r.Cmd == "src/utils.go" {
foundUtils = true
}
}
if !foundUtils {
t.Errorf("Deep scan did not find src/utils.go")
}
})
t.Run("Hidden files are skipped", func(t *testing.T) {
gen := spec.FileGenerator()
results := gen([]string{"ls", ""}, "ls ", "")
for _, r := range results {
if strings.HasPrefix(r.Cmd, ".") {
t.Errorf("Hidden file %q should be skipped", r.Cmd)
}
}
})
}
-95
View File
@@ -1,95 +0,0 @@
package tests
import (
"strings"
"sync"
"testing"
"github.com/versenilvis/iris/spec"
)
func TestLookup(t *testing.T) {
// Setup Registry
spec.Registry = make(map[string]*spec.Spec)
spec.Register(&spec.Spec{
Name: "git",
Subcommands: []spec.Subcommand{
{Name: "commit", Options: []spec.Option{{Name: "--message"}}, MaxArgs: 1},
{Name: "remote", Subcommands: []spec.Subcommand{{Name: "add"}}},
},
Options: []spec.Option{{Name: "--verbose"}},
})
// Setup Aliases
spec.ShellAliases = map[string]string{
"gca": "git commit -a",
"ta": "tmux a -t",
}
tests := []struct {
name string
input string
minResults int
mustContain string
}{
{"Top-level", "gi", 1, "git"},
{"Subcommand", "git ", 1, "git commit"},
{"Alias expansion", "gca", 1, "git commit -a"},
{"Alias with space", "ta", 1, "tmux a -t"},
{"Deep subcommand", "git remote ", 1, "git remote add"},
{"Option dedup", "git --verbose -", 0, ""},
{"Flag with value ignore", "git --output=json ", 2, "git --output=json commit"},
{"Unknown root command", "unknowncmd ", 0, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := spec.Lookup(tt.input)
if len(results) < tt.minResults {
t.Errorf("Lookup(%q) got %d results; want at least %d", tt.input, len(results), tt.minResults)
}
if tt.mustContain != "" {
found := false
for _, r := range results {
if strings.Contains(r.Cmd, tt.mustContain) || strings.Contains(r.Desc, tt.mustContain) {
found = true
break
}
}
if !found {
t.Errorf("Lookup(%q) results did not contain %q", tt.input, tt.mustContain)
}
}
})
}
}
func TestLookupConcurrent(t *testing.T) {
spec.Registry = make(map[string]*spec.Spec)
spec.Register(&spec.Spec{
Name: "git",
Subcommands: []spec.Subcommand{
{Name: "commit", Options: []spec.Option{{Name: "--message"}}, MaxArgs: 1},
},
})
spec.ShellAliases = map[string]string{
"gca": "git commit -a",
}
var wg sync.WaitGroup
const goroutines = 10
const iterations = 50
for range goroutines {
wg.Add(1)
go func() {
defer wg.Done()
for range iterations {
_ = spec.Lookup("gca")
_ = spec.Lookup("git ")
}
}()
}
wg.Wait()
}
-55
View File
@@ -1,55 +0,0 @@
package tests
import (
"reflect"
"testing"
"github.com/versenilvis/iris/spec"
)
func TestTokenize(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{"Empty input", "", []string{""}},
{"Trailing space", "git ", []string{"git", ""}},
{"Multi-space", "git add", []string{"git", "add"}},
{"Quoted string", "git commit -m \"hello world\"", []string{"git", "commit", "-m", "hello world"}},
{"Quote not closed", "git commit -m \"hello", []string{"git", "commit", "-m", "hello"}},
{"Single quote", "git commit -m 'hello world'", []string{"git", "commit", "-m", "hello world"}},
{"Backslash escape", "git commit -m \"hello\\ world\"", []string{"git", "commit", "-m", "hello world"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := spec.Tokenize(tt.input)
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("Tokenize(%q) = %v; want %v", tt.input, got, tt.expected)
}
})
}
}
func TestHasPrefix(t *testing.T) {
tests := []struct {
name string
s string
prefix string
want bool
}{
{"Case insensitive", "Hello", "hel", true},
{"Unicode support", "Thử nghiệm", "thử", true},
{"Prefix longer", "Iris", "Iris-Longer", false},
{"Empty prefix", "Iris", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := spec.HasPrefix(tt.s, tt.prefix); got != tt.want {
t.Errorf("HasPrefix(%q, %q) = %v; want %v", tt.s, tt.prefix, got, tt.want)
}
})
}
}