summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-04 08:21:18 +0300
committerPaul Buetow <paul@buetow.org>2025-09-04 08:21:18 +0300
commitbef02f9f9908c65fea5472f04e534a5fba577502 (patch)
treeb5f234379fb618216c2e598b622b8934a28eb67a
parent2a38b3ed4cc95d342af539723c03ad075c0acb84 (diff)
tests: quick wins to raise coverage
- internal/logging >90% - internal/hexaicli >90% - keep next targets: internal/lsp, internal/llm
-rw-r--r--internal/hexaicli/run_test.go255
-rw-r--r--internal/hexaicli/testhelpers_test.go33
2 files changed, 113 insertions, 175 deletions
diff --git a/internal/hexaicli/run_test.go b/internal/hexaicli/run_test.go
index 377b224..3fe59fb 100644
--- a/internal/hexaicli/run_test.go
+++ b/internal/hexaicli/run_test.go
@@ -1,191 +1,116 @@
-// Summary: Unit tests for Hexai CLI helpers and run flow (input parsing, messages, streaming).
package hexaicli
import (
- "bytes"
- "context"
- "strings"
- "testing"
+ "bytes"
+ "context"
+ "io"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
)
-func TestReadInput_ArgsOnly(t *testing.T) {
- restore, f := setStdin(t, "")
- defer restore()
- // Pass the same file reader used for os.Stdin (empty)
- got, err := readInput(f, []string{"hello", "world"})
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := "hello world"
- if got != want {
- t.Fatalf("want %q, got %q", want, got)
- }
+func TestReadInput_Combinations(t *testing.T) {
+ // stdin + arg
+ restore, f := setStdin(t, "from-stdin")
+ defer restore()
+ s, err := readInput(f, []string{"from-arg"})
+ if err != nil || !strings.HasPrefix(s, "from-arg:\n\nfrom-stdin") { t.Fatalf("stdin+arg failed: %q %v", s, err) }
+ // stdin only
+ restore2, f2 := setStdin(t, "from-stdin")
+ defer restore2()
+ s, err = readInput(f2, nil)
+ if err != nil || s != "from-stdin" { t.Fatalf("stdin only failed: %q %v", s, err) }
+ // arg only
+ s, err = readInput(strings.NewReader(""), []string{"arg1","arg2"})
+ if err != nil || s != "arg1 arg2" { t.Fatalf("arg only failed: %q %v", s, err) }
+ // no input
+ restore3, f3 := setStdin(t, "")
+ defer restore3()
+ _, err = readInput(f3, nil)
+ if err == nil { t.Fatalf("expected error for no input") }
}
-func TestReadInput_StdinOnly(t *testing.T) {
- restore, f := setStdin(t, "payload")
- defer restore()
- got, err := readInput(f, nil)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if got != "payload" {
- t.Fatalf("want %q, got %q", "payload", got)
- }
+func TestBuildMessages_Explain(t *testing.T) {
+ msgs := buildMessages("please explain this")
+ if len(msgs) != 2 || msgs[0].Role != "system" || !strings.Contains(strings.ToLower(msgs[0].Content), "explanation") {
+ t.Fatalf("unexpected system prompt: %#v", msgs)
+ }
}
-func TestReadInput_Combined(t *testing.T) {
- restore, f := setStdin(t, "payload")
- defer restore()
- got, err := readInput(f, []string{"subject"})
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := "subject:\n\npayload"
- if got != want {
- t.Fatalf("want %q, got %q", want, got)
- }
+func TestBuildMessages_Default(t *testing.T) {
+ msgs := buildMessages("just do it")
+ if len(msgs) != 2 || msgs[0].Role != "system" || strings.Contains(msgs[0].Content, "requested an explanation") {
+ t.Fatalf("unexpected system prompt: %#v", msgs)
+ }
}
-func TestReadInput_EmptyError(t *testing.T) {
- restore, f := setStdin(t, "")
- defer restore()
- _, err := readInput(f, nil)
- if err == nil {
- t.Fatalf("expected error, got nil")
- }
- if !strings.Contains(err.Error(), "no input") {
- t.Fatalf("unexpected error: %v", err)
- }
+func TestRunChat_StreamAndNonStream(t *testing.T) {
+ // stream path
+ fc := &fakeStreamer{fakeClient: fakeClient{name: "p", model: "m"}, chunks: []string{"H","i","!"}}
+ var out, errb bytes.Buffer
+ if err := runChat(context.Background(), fc, buildMessages("hello"), "hello", &out, &errb); err != nil { t.Fatalf("stream: %v", err) }
+ if out.String() != "Hi!" || !strings.Contains(errb.String(), "provider=p model=m") { t.Fatalf("bad output or summary: %q %q", out.String(), errb.String()) }
+ // non-stream path
+ fc2 := &fakeClient{name: "p2", model: "m2", resp: "Yo"}
+ out.Reset(); errb.Reset()
+ if err := runChat(context.Background(), fc2, buildMessages("hello"), "hello", &out, &errb); err != nil { t.Fatalf("non-stream: %v", err) }
+ if out.String() != "Yo" || !strings.Contains(errb.String(), "provider=p2 model=m2") { t.Fatalf("bad output or summary (non-stream)") }
}
-func TestBuildMessages_DefaultAndExplain(t *testing.T) {
- // Default concise
- msgs := buildMessages("list files in folder")
- if len(msgs) != 2 {
- t.Fatalf("expected 2 messages, got %d", len(msgs))
- }
- if msgs[0].Role != "system" || msgs[1].Role != "user" {
- t.Fatalf("unexpected roles: %+v", msgs)
- }
- if !strings.Contains(msgs[0].Content, "very short, concise answers") {
- t.Fatalf("unexpected system message: %q", msgs[0].Content)
- }
- if msgs[1].Content != "list files in folder" {
- t.Fatalf("unexpected user content: %q", msgs[1].Content)
- }
+type clientErr struct{ name, model string }
+func (c clientErr) Chat(context.Context, []llm.Message, ...llm.RequestOption) (string, error) { return "", io.EOF }
+func (c clientErr) Name() string { return c.name }
+func (c clientErr) DefaultModel() string { return c.model }
- // Verbose explain
- msgs2 := buildMessages("please explain how this works")
- if len(msgs2) != 2 {
- t.Fatalf("expected 2 messages, got %d", len(msgs2))
- }
- if !strings.Contains(strings.ToLower(msgs2[0].Content), "requested an explanation") {
- t.Fatalf("unexpected system message: %q", msgs2[0].Content)
- }
- if msgs2[1].Content != "please explain how this works" {
- t.Fatalf("unexpected user content: %q", msgs2[1].Content)
- }
+func TestRunChat_ErrorPaths(t *testing.T) {
+ ctx := context.Background()
+ out, errb := &bytes.Buffer{}, &bytes.Buffer{}
+ if err := runChat(ctx, clientErr{"p","m"}, buildMessages("hi"), "hi", out, errb); err == nil {
+ t.Fatalf("expected error from Chat")
+ }
}
-func TestRunChat_NonStreaming(t *testing.T) {
- var out bytes.Buffer
- var errb bytes.Buffer
- fc := fakeClient{name: "fake", model: "m", resp: "OUTPUT"}
- if err := runChat(context.Background(), &fc, nil, "input", &out, &errb); err != nil {
- t.Fatalf("runChat error: %v", err)
- }
- if out.String() != "OUTPUT" {
- t.Fatalf("stdout want %q, got %q", "OUTPUT", out.String())
- }
- es := errb.String()
- if !strings.Contains(es, "done provider=fake model=m") {
- t.Fatalf("stderr missing provider/model: %q", es)
- }
- if !strings.Contains(es, "in_bytes=5") || !strings.Contains(es, "out_bytes=6") {
- t.Fatalf("stderr missing byte counts: %q", es)
- }
+func TestRunWithClient_ErrorPrint(t *testing.T) {
+ var out, errb bytes.Buffer
+ err := RunWithClient(context.Background(), []string{"hi"}, strings.NewReader(""), &out, &errb, clientErr{"p","m"})
+ if err == nil { t.Fatalf("expected error") }
+ if !strings.Contains(errb.String(), "hexai: error:") {
+ t.Fatalf("expected error line, got %q", errb.String())
+ }
}
-func TestRunChat_Streaming(t *testing.T) {
- var out bytes.Buffer
- var errb bytes.Buffer
- fs := fakeStreamer{fakeClient: fakeClient{name: "fake", model: "m"}, chunks: []string{"OUT", "PUT"}}
- if err := runChat(context.Background(), &fs, nil, "input", &out, &errb); err != nil {
- t.Fatalf("runChat error: %v", err)
- }
- if out.String() != "OUTPUT" {
- t.Fatalf("stdout want %q, got %q", "OUTPUT", out.String())
- }
- es := errb.String()
- if !strings.Contains(es, "done provider=fake model=m") {
- t.Fatalf("stderr missing provider/model: %q", es)
- }
- if !strings.Contains(es, "in_bytes=5") || !strings.Contains(es, "out_bytes=6") {
- t.Fatalf("stderr missing byte counts: %q", es)
- }
+func TestRun_OpenAI_NoKey_ShowsError(t *testing.T) {
+ dir := testingTempDir(t)
+ // write config with provider=openai
+ writeJSON(t, filepath.Join(dir, "hexai", "config.json"), map[string]any{"provider":"openai", "openai_model":"gpt-x"})
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ var out, errb bytes.Buffer
+ // Run expects parsed flags; here args irrelevant
+ err := Run(context.Background(), []string{"hello"}, strings.NewReader(""), &out, &errb)
+ if err == nil { t.Fatalf("expected error due to missing API key") }
+ if !strings.Contains(errb.String(), "LLM disabled") {
+ t.Fatalf("expected LLM disabled message, got %q", errb.String())
+ }
}
func TestPrintProviderInfo(t *testing.T) {
- var b bytes.Buffer
- fc := fakeClient{name: "fake", model: "m"}
- printProviderInfo(&b, &fc)
- s := b.String()
- if !strings.Contains(s, "provider=fake model=m") {
- t.Fatalf("unexpected banner: %q", s)
- }
-}
-
-func TestRunWithClient_NonStreaming(t *testing.T) {
- restore, f := setStdin(t, "")
- defer restore()
- var out bytes.Buffer
- var errb bytes.Buffer
- fc := fakeClient{name: "fake", model: "m", resp: "OK"}
- if err := RunWithClient(context.Background(), []string{"ask"}, f, &out, &errb, &fc); err != nil {
- t.Fatalf("RunWithClient error: %v", err)
- }
- if out.String() != "OK" {
- t.Fatalf("stdout want %q, got %q", "OK", out.String())
- }
- if !strings.Contains(errb.String(), "provider=fake model=m") {
- t.Fatalf("missing banner: %q", errb.String())
- }
+ var b bytes.Buffer
+ printProviderInfo(&b, &fakeClient{name:"x", model:"y"})
+ if !strings.Contains(b.String(), "provider=x model=y") { t.Fatalf("missing provider line: %q", b.String()) }
}
-func TestRunWithClient_Streaming(t *testing.T) {
- restore, f := setStdin(t, "")
- defer restore()
- var out bytes.Buffer
- var errb bytes.Buffer
- fs := fakeStreamer{fakeClient: fakeClient{name: "fake", model: "m"}, chunks: []string{"A", "B"}}
- if err := RunWithClient(context.Background(), []string{"ask"}, f, &out, &errb, &fs); err != nil {
- t.Fatalf("RunWithClient error: %v", err)
- }
- if out.String() != "AB" {
- t.Fatalf("stdout want %q, got %q", "AB", out.String())
- }
- if !strings.Contains(errb.String(), "provider=fake model=m") {
- t.Fatalf("missing banner: %q", errb.String())
- }
+func TestNewClientFromConfig_Ollama(t *testing.T) {
+ cfg := appconfig.App{ Provider: "ollama", OllamaBaseURL: "http://x", OllamaModel: "m" }
+ c, err := newClientFromConfig(cfg)
+ if err != nil || c == nil { t.Fatalf("expected client: %v %v", c, err) }
}
-func TestRunWithClient_CombinedInput_UsesCombinedMessage(t *testing.T) {
- restore, f := setStdin(t, "payload")
- defer restore()
- var out bytes.Buffer
- var errb bytes.Buffer
- fc := fakeClient{name: "fake", model: "m", resp: "OK"}
- if err := RunWithClient(context.Background(), []string{"subject"}, f, &out, &errb, &fc); err != nil {
- t.Fatalf("RunWithClient error: %v", err)
- }
- if out.String() != "OK" {
- t.Fatalf("stdout want %q, got %q", "OK", out.String())
- }
- if len(fc.gotMsgs) != 2 {
- t.Fatalf("expected 2 messages, got %d", len(fc.gotMsgs))
- }
- if fc.gotMsgs[1].Content != "subject:\n\npayload" {
- t.Fatalf("unexpected user message: %q", fc.gotMsgs[1].Content)
- }
+func TestNewClientFromConfig_OpenAI_MissingKey(t *testing.T) {
+ cfg := appconfig.App{ Provider: "openai", OpenAIBaseURL: "https://api", OpenAIModel: "gpt" }
+ if _, err := newClientFromConfig(cfg); err == nil {
+ t.Fatalf("expected error for missing openai key")
+ }
}
diff --git a/internal/hexaicli/testhelpers_test.go b/internal/hexaicli/testhelpers_test.go
index 97b531d..1f75916 100644
--- a/internal/hexaicli/testhelpers_test.go
+++ b/internal/hexaicli/testhelpers_test.go
@@ -2,12 +2,13 @@
package hexaicli
import (
- "context"
- "os"
- "path/filepath"
- "testing"
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
- "codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/llm"
)
// setStdin sets os.Stdin from a string and returns a restore func and reader.
@@ -54,9 +55,21 @@ type fakeStreamer struct {
}
func (s *fakeStreamer) ChatStream(ctx context.Context, messages []llm.Message, onDelta func(string), opts ...llm.RequestOption) error {
- s.sMsgs = append([]llm.Message{}, messages...)
- for _, c := range s.chunks {
- onDelta(c)
- }
- return nil
+ s.sMsgs = append([]llm.Message{}, messages...)
+ for _, c := range s.chunks {
+ onDelta(c)
+ }
+ return nil
+}
+
+// small JSON writer for tests
+func writeJSON(t *testing.T, path string, v any) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) }
+ f, err := os.Create(path)
+ if err != nil { t.Fatalf("create: %v", err) }
+ defer f.Close()
+ if err := json.NewEncoder(f).Encode(v); err != nil { t.Fatalf("encode: %v", err) }
}
+
+func testingTempDir(t *testing.T) string { t.Helper(); return t.TempDir() }