diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-04 08:21:18 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-04 08:21:18 +0300 |
| commit | bef02f9f9908c65fea5472f04e534a5fba577502 (patch) | |
| tree | b5f234379fb618216c2e598b622b8934a28eb67a | |
| parent | 2a38b3ed4cc95d342af539723c03ad075c0acb84 (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.go | 255 | ||||
| -rw-r--r-- | internal/hexaicli/testhelpers_test.go | 33 |
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() } |
