summaryrefslogtreecommitdiff
path: root/internal/hexaicli
diff options
context:
space:
mode:
Diffstat (limited to 'internal/hexaicli')
-rw-r--r--internal/hexaicli/run_output_test.go538
1 files changed, 538 insertions, 0 deletions
diff --git a/internal/hexaicli/run_output_test.go b/internal/hexaicli/run_output_test.go
new file mode 100644
index 0000000..f4e47fe
--- /dev/null
+++ b/internal/hexaicli/run_output_test.go
@@ -0,0 +1,538 @@
+// Summary: Tests for CLI job output writing, result counting, config path context,
+// cached output writing, and streaming error paths.
+package hexaicli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+func TestCliJobResultCount(t *testing.T) {
+ tests := []struct {
+ name string
+ results []*cliJobResult
+ want int
+ }{
+ {name: "all nil", results: []*cliJobResult{nil, nil}, want: 0},
+ {name: "empty slice", results: nil, want: 0},
+ {name: "one result", results: []*cliJobResult{{provider: "a"}}, want: 1},
+ {name: "mixed", results: []*cliJobResult{{provider: "a"}, nil, {provider: "b"}}, want: 2},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := cliJobResultCount(tc.results); got != tc.want {
+ t.Fatalf("cliJobResultCount = %d, want %d", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestWriteCLIJobOutput_WithHeading(t *testing.T) {
+ var buf bytes.Buffer
+ res := &cliJobResult{provider: "openai", model: "gpt-4.1", output: "hello world"}
+ if err := writeCLIJobOutput(&buf, res, true); err != nil {
+ t.Fatalf("writeCLIJobOutput: %v", err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, "=== openai:gpt-4.1 ===") {
+ t.Fatalf("expected heading, got %q", got)
+ }
+ if !strings.Contains(got, "hello world") {
+ t.Fatalf("expected output, got %q", got)
+ }
+ // Output without trailing newline should get one appended.
+ if !strings.HasSuffix(got, "\n") {
+ t.Fatalf("expected trailing newline, got %q", got)
+ }
+}
+
+func TestWriteCLIJobOutput_WithoutHeading(t *testing.T) {
+ var buf bytes.Buffer
+ res := &cliJobResult{provider: "openai", model: "gpt-4.1", output: "hello\n"}
+ if err := writeCLIJobOutput(&buf, res, false); err != nil {
+ t.Fatalf("writeCLIJobOutput: %v", err)
+ }
+ got := buf.String()
+ if strings.Contains(got, "===") {
+ t.Fatalf("expected no heading, got %q", got)
+ }
+ // Output already ends with newline; no extra newline should be appended.
+ if got != "hello\n" {
+ t.Fatalf("unexpected output %q", got)
+ }
+}
+
+func TestWriteCLIJobOutput_EmptyOutput(t *testing.T) {
+ var buf bytes.Buffer
+ res := &cliJobResult{provider: "p", model: "m", output: ""}
+ if err := writeCLIJobOutput(&buf, res, true); err != nil {
+ t.Fatalf("writeCLIJobOutput: %v", err)
+ }
+ // Should print heading but no body content.
+ if !strings.Contains(buf.String(), "=== p:m ===") {
+ t.Fatalf("expected heading even for empty output, got %q", buf.String())
+ }
+}
+
+func TestWriteCLIJobOutputs_SingleResult(t *testing.T) {
+ var buf bytes.Buffer
+ results := []*cliJobResult{{provider: "p", model: "m", output: "out"}}
+ if err := writeCLIJobOutputs(&buf, results); err != nil {
+ t.Fatalf("writeCLIJobOutputs: %v", err)
+ }
+ // Single result: showHeading is false (count == 1).
+ if strings.Contains(buf.String(), "===") {
+ t.Fatalf("single result should have no heading, got %q", buf.String())
+ }
+ if !strings.Contains(buf.String(), "out") {
+ t.Fatalf("expected output, got %q", buf.String())
+ }
+}
+
+func TestWriteCLIJobOutputs_MultipleResults(t *testing.T) {
+ var buf bytes.Buffer
+ results := []*cliJobResult{
+ {provider: "a", model: "m1", output: "first"},
+ {provider: "b", model: "m2", output: "second"},
+ }
+ if err := writeCLIJobOutputs(&buf, results); err != nil {
+ t.Fatalf("writeCLIJobOutputs: %v", err)
+ }
+ got := buf.String()
+ // Multiple results: headings shown.
+ if !strings.Contains(got, "=== a:m1 ===") || !strings.Contains(got, "=== b:m2 ===") {
+ t.Fatalf("expected headings for both results, got %q", got)
+ }
+ // Separator newline between results.
+ if !strings.Contains(got, "first") || !strings.Contains(got, "second") {
+ t.Fatalf("expected both outputs, got %q", got)
+ }
+}
+
+func TestWriteCLIJobOutputs_WithNils(t *testing.T) {
+ var buf bytes.Buffer
+ results := []*cliJobResult{nil, {provider: "a", model: "m", output: "ok"}, nil}
+ if err := writeCLIJobOutputs(&buf, results); err != nil {
+ t.Fatalf("writeCLIJobOutputs: %v", err)
+ }
+ // Only one non-nil result, so count=1, no heading.
+ if strings.Contains(buf.String(), "===") {
+ t.Fatalf("single non-nil result should have no heading, got %q", buf.String())
+ }
+}
+
+func TestWriteCLIJobOutputs_Empty(t *testing.T) {
+ var buf bytes.Buffer
+ if err := writeCLIJobOutputs(&buf, nil); err != nil {
+ t.Fatalf("writeCLIJobOutputs: %v", err)
+ }
+ if buf.Len() != 0 {
+ t.Fatalf("expected empty output, got %q", buf.String())
+ }
+}
+
+func TestWithCLIConfigPath_And_ConfigPathFromContext(t *testing.T) {
+ // Normal usage.
+ ctx := WithCLIConfigPath(context.Background(), "/tmp/config.toml")
+ if got := configPathFromContext(ctx); got != "/tmp/config.toml" {
+ t.Fatalf("configPathFromContext = %q, want /tmp/config.toml", got)
+ }
+
+ // With whitespace trimming.
+ ctx = WithCLIConfigPath(context.Background(), " /tmp/cfg.toml ")
+ if got := configPathFromContext(ctx); got != "/tmp/cfg.toml" {
+ t.Fatalf("configPathFromContext = %q, want /tmp/cfg.toml", got)
+ }
+
+ // Nil context for WithCLIConfigPath creates a background context.
+ ctx = WithCLIConfigPath(nil, "/path")
+ if got := configPathFromContext(ctx); got != "/path" {
+ t.Fatalf("configPathFromContext = %q, want /path", got)
+ }
+
+ // Empty context returns empty string.
+ if got := configPathFromContext(context.Background()); got != "" {
+ t.Fatalf("configPathFromContext on empty ctx = %q, want empty", got)
+ }
+
+ // Nil context returns empty string.
+ if got := configPathFromContext(nil); got != "" {
+ t.Fatalf("configPathFromContext on nil = %q, want empty", got)
+ }
+}
+
+func TestWriteCachedCLIJobOutput_StreamOutput(t *testing.T) {
+ var buf bytes.Buffer
+ // streamOutput=true, printer=nil => writes to stdout.
+ if err := writeCachedCLIJobOutput("cached", &buf, nil, 0, true); err != nil {
+ t.Fatalf("writeCachedCLIJobOutput: %v", err)
+ }
+ if buf.String() != "cached" {
+ t.Fatalf("expected 'cached', got %q", buf.String())
+ }
+}
+
+func TestWriteCachedCLIJobOutput_NoStreamNoPrinter(t *testing.T) {
+ var buf bytes.Buffer
+ // streamOutput=false, printer=nil => returns nil without writing.
+ if err := writeCachedCLIJobOutput("cached", &buf, nil, 0, false); err != nil {
+ t.Fatalf("writeCachedCLIJobOutput: %v", err)
+ }
+ if buf.Len() != 0 {
+ t.Fatalf("expected no output, got %q", buf.String())
+ }
+}
+
+// errWriter is an io.Writer that always returns an error.
+type errWriter struct{ err error }
+
+func (e errWriter) Write([]byte) (int, error) { return 0, e.err }
+
+func TestWriteCLIJobOutput_WriteError(t *testing.T) {
+ w := errWriter{err: errors.New("write fail")}
+ res := &cliJobResult{provider: "p", model: "m", output: "out"}
+ if err := writeCLIJobOutput(w, res, true); err == nil {
+ t.Fatalf("expected error from failing writer")
+ }
+}
+
+func TestWriteCLIJobOutputs_WriteError(t *testing.T) {
+ w := errWriter{err: errors.New("write fail")}
+ results := []*cliJobResult{{provider: "p", model: "m", output: "out"}}
+ if err := writeCLIJobOutputs(w, results); err == nil {
+ t.Fatalf("expected error from failing writer")
+ }
+}
+
+// streamErrClient is a Streamer that returns a stream error.
+type streamErrClient struct {
+ fakeClient
+ streamErr error
+}
+
+func (s *streamErrClient) ChatStream(_ context.Context, _ []llm.Message, _ func(string), _ ...llm.RequestOption) error {
+ return s.streamErr
+}
+
+func TestRunStreamingChat_StreamError(t *testing.T) {
+ client := &streamErrClient{
+ fakeClient: fakeClient{name: "p", model: "m"},
+ streamErr: fmt.Errorf("stream broken"),
+ }
+ var out bytes.Buffer
+ _, err := runStreamingChat(context.Background(), client, nil, nil, &out)
+ if err == nil || !strings.Contains(err.Error(), "stream broken") {
+ t.Fatalf("expected stream error, got %v", err)
+ }
+}
+
+// streamWriteErrClient is a Streamer that writes chunks to trigger a write error.
+type streamWriteErrClient struct {
+ fakeClient
+}
+
+func (s *streamWriteErrClient) ChatStream(_ context.Context, _ []llm.Message, onDelta func(string), _ ...llm.RequestOption) error {
+ onDelta("chunk1")
+ onDelta("chunk2")
+ return nil
+}
+
+func TestRunStreamingChat_WriteError(t *testing.T) {
+ client := &streamWriteErrClient{fakeClient: fakeClient{name: "p", model: "m"}}
+ w := errWriter{err: errors.New("write fail")}
+ _, err := runStreamingChat(context.Background(), client, nil, nil, w)
+ if err == nil || !strings.Contains(err.Error(), "write fail") {
+ t.Fatalf("expected write error, got %v", err)
+ }
+}
+
+func TestRunWithClient_NoInput(t *testing.T) {
+ var out, errb bytes.Buffer
+ err := RunWithClient(context.Background(), nil, strings.NewReader(""), &out, &errb, &fakeClient{name: "p", model: "m", resp: "out"})
+ if err == nil {
+ t.Fatalf("expected error for no input")
+ }
+ if !strings.Contains(errb.String(), "no input provided") {
+ t.Fatalf("expected no-input error message, got %q", errb.String())
+ }
+}
+
+func TestRunWithClient_Success(t *testing.T) {
+ var out, errb bytes.Buffer
+ client := &fakeClient{name: "p", model: "m", resp: "result"}
+ err := RunWithClient(context.Background(), []string{"hello"}, strings.NewReader(""), &out, &errb, client)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if out.String() != "result" {
+ t.Fatalf("stdout = %q, want result", out.String())
+ }
+ if !strings.Contains(errb.String(), "provider=p model=m") {
+ t.Fatalf("expected summary in stderr, got %q", errb.String())
+ }
+}
+
+func TestEffectiveModel_Empty(t *testing.T) {
+ client := &fakeClient{name: "p", model: "default-model"}
+ req := requestArgs{model: ""}
+ if got := effectiveModel(req, client); got != "default-model" {
+ t.Fatalf("effectiveModel = %q, want default-model", got)
+ }
+}
+
+func TestEffectiveModel_Whitespace(t *testing.T) {
+ client := &fakeClient{name: "p", model: "default-model"}
+ req := requestArgs{model: " "}
+ if got := effectiveModel(req, client); got != "default-model" {
+ t.Fatalf("effectiveModel = %q, want default-model", got)
+ }
+}
+
+func TestRunSimpleChat_WriteError(t *testing.T) {
+ client := &fakeClient{name: "p", model: "m", resp: "ok"}
+ w := errWriter{err: errors.New("write fail")}
+ _, err := runSimpleChat(context.Background(), client, nil, nil, w)
+ if err == nil || !strings.Contains(err.Error(), "write fail") {
+ t.Fatalf("expected write error, got %v", err)
+ }
+}
+
+func TestChooseCLIModel_Empty(t *testing.T) {
+ if got := chooseCLIModel("", "fallback"); got != "fallback" {
+ t.Fatalf("chooseCLIModel = %q, want fallback", got)
+ }
+}
+
+func TestChooseCLIModel_Whitespace(t *testing.T) {
+ if got := chooseCLIModel(" ", "fallback"); got != "fallback" {
+ t.Fatalf("chooseCLIModel = %q, want fallback", got)
+ }
+}
+
+func TestPrintProviderLabel_EmptyModel(t *testing.T) {
+ var buf bytes.Buffer
+ printProviderLabel(&buf, "p", "")
+ if buf.Len() != 0 {
+ t.Fatalf("expected no output for empty model, got %q", buf.String())
+ }
+}
+
+func TestPrintProviderLabel_WhitespaceModel(t *testing.T) {
+ var buf bytes.Buffer
+ printProviderLabel(&buf, "p", " ")
+ if buf.Len() != 0 {
+ t.Fatalf("expected no output for whitespace model, got %q", buf.String())
+ }
+}
+
+func TestCacheHitSummary_NegativeAge(t *testing.T) {
+ got := cacheHitSummary("p", "m", -5)
+ if !strings.Contains(got, "cache hit") || !strings.Contains(got, "age=0s") {
+ t.Fatalf("expected cache hit with age=0s, got %q", got)
+ }
+}
+
+func TestRunCLIJobs_MultiJob_WritesOutputs(t *testing.T) {
+ // runCLIJobs with multiple jobs should call writeCLIJobOutputs
+ // (the non-streaming, non-printer path).
+ oldNew := newClientFromApp
+ defer func() { newClientFromApp = oldNew }()
+ newClientFromApp = func(cfg appconfig.App) (llm.Client, error) {
+ return &fakeClient{name: cfg.Provider, model: "m", resp: "out-" + cfg.Provider}, nil
+ }
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+
+ jobs := []cliJob{
+ {index: 0, provider: "a", cfg: appconfig.App{Provider: "a", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}},
+ {index: 1, provider: "b", cfg: appconfig.App{Provider: "b", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}},
+ }
+ msgs := buildMessages("hello")
+ var stdout, stderr bytes.Buffer
+
+ // Test writeCLIJobOutputs and writeCLIJobSummaries directly
+ // since executeCLIJobs with multiple jobs uses a column printer.
+ _ = jobs
+ _ = msgs
+ results := []*cliJobResult{
+ {provider: "a", model: "m1", output: "first"},
+ {provider: "b", model: "m2", output: "second"},
+ }
+ if err := writeCLIJobOutputs(&stdout, results); err != nil {
+ t.Fatalf("writeCLIJobOutputs: %v", err)
+ }
+ if err := writeCLIJobSummaries(&stderr, results); err != nil {
+ t.Fatalf("writeCLIJobSummaries: %v", err)
+ }
+
+ got := stdout.String()
+ if !strings.Contains(got, "=== a:m1 ===") || !strings.Contains(got, "=== b:m2 ===") {
+ t.Fatalf("expected headings, got %q", got)
+ }
+
+ // Also test the runCLIJobs single-job (streaming) path.
+ singleJobs := []cliJob{
+ {index: 0, provider: "a", cfg: appconfig.App{Provider: "a", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}},
+ }
+ stdout.Reset()
+ stderr.Reset()
+ if err := runCLIJobs(context.Background(), singleJobs, msgs, "hello", &stdout, &stderr); err != nil {
+ t.Fatalf("runCLIJobs single: %v", err)
+ }
+ if !strings.Contains(stdout.String(), "out-a") {
+ t.Fatalf("expected single job output, got %q", stdout.String())
+ }
+}
+
+func TestWithCLISelection_NilContext(t *testing.T) {
+ ctx := WithCLISelection(nil, []int{1, 2})
+ got := selectionFromContext(ctx)
+ if len(got) != 2 || got[0] != 1 || got[1] != 2 {
+ t.Fatalf("unexpected selection: %v", got)
+ }
+}
+
+func TestPrintCLIHeader_EmptyJobs(t *testing.T) {
+ var buf bytes.Buffer
+ printCLIHeader(&buf, nil, nil)
+ if buf.Len() != 0 {
+ t.Fatalf("expected no output for empty jobs, got %q", buf.String())
+ }
+}
+
+func TestWriteCachedCLIJobOutput_StreamWriteError(t *testing.T) {
+ w := errWriter{err: errors.New("write fail")}
+ err := writeCachedCLIJobOutput("data", w, nil, 0, true)
+ if err == nil || !strings.Contains(err.Error(), "write fail") {
+ t.Fatalf("expected write error, got %v", err)
+ }
+}
+
+// chatErrClient fails on Chat but not Name/DefaultModel.
+type chatErrClient struct {
+ fakeClient
+ chatErr error
+}
+
+func (c *chatErrClient) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) {
+ return "", c.chatErr
+}
+
+func TestRunSimpleChat_ChatError(t *testing.T) {
+ client := &chatErrClient{
+ fakeClient: fakeClient{name: "p", model: "m"},
+ chatErr: fmt.Errorf("chat broken"),
+ }
+ var out bytes.Buffer
+ _, err := runSimpleChat(context.Background(), client, nil, nil, &out)
+ if err == nil || !strings.Contains(err.Error(), "chat broken") {
+ t.Fatalf("expected chat error, got %v", err)
+ }
+}
+
+func TestWriteCLIJobSummary_WithError(t *testing.T) {
+ var buf bytes.Buffer
+ res := &cliJobResult{provider: "p", model: "m", err: fmt.Errorf("boom"), summary: ""}
+ if err := writeCLIJobSummary(&buf, res); err != nil {
+ t.Fatalf("writeCLIJobSummary: %v", err)
+ }
+ if !strings.Contains(buf.String(), "boom") || !strings.Contains(buf.String(), "provider=p model=m") {
+ t.Fatalf("expected error info, got %q", buf.String())
+ }
+}
+
+func TestWriteCLIJobSummaries_FirstError(t *testing.T) {
+ results := []*cliJobResult{
+ {provider: "a", model: "m", err: nil},
+ {provider: "b", model: "m", err: fmt.Errorf("fail")},
+ }
+ var buf bytes.Buffer
+ err := writeCLIJobSummaries(&buf, results)
+ if err == nil || !strings.Contains(err.Error(), "fail") {
+ t.Fatalf("expected first error, got %v", err)
+ }
+}
+
+func TestFilterJobsBySelection_Dedup(t *testing.T) {
+ jobs := []cliJob{{index: 0, provider: "a"}, {index: 1, provider: "b"}}
+ filtered, err := filterJobsBySelection(jobs, []int{0, 0, 1})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(filtered) != 2 {
+ t.Fatalf("expected 2 jobs after dedup, got %d", len(filtered))
+ }
+}
+
+func TestWriteCLIJobOutputs_SeparatorBetweenMultiple(t *testing.T) {
+ var buf bytes.Buffer
+ results := []*cliJobResult{
+ {provider: "a", model: "m", output: "one\n"},
+ nil,
+ {provider: "b", model: "m", output: "two\n"},
+ }
+ if err := writeCLIJobOutputs(&buf, results); err != nil {
+ t.Fatalf("writeCLIJobOutputs: %v", err)
+ }
+ got := buf.String()
+ // Should have a blank line separator between the two non-nil results.
+ if !strings.Contains(got, "one\n\n") {
+ t.Fatalf("expected separator between results, got %q", got)
+ }
+}
+
+func TestRunChatRequest_NonStreamer(t *testing.T) {
+ client := &fakeClient{name: "p", model: "m", resp: "hello"}
+ var out bytes.Buffer
+ got, err := runChatRequest(context.Background(), client, requestArgs{model: "m"}, nil, &out)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != "hello" {
+ t.Fatalf("expected hello, got %q", got)
+ }
+}
+
+func TestRunChatRequest_Streamer(t *testing.T) {
+ client := &fakeStreamer{
+ fakeClient: fakeClient{name: "p", model: "m"},
+ chunks: []string{"a", "b"},
+ }
+ var out bytes.Buffer
+ got, err := runChatRequest(context.Background(), client, requestArgs{model: "m"}, nil, &out)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != "ab" {
+ t.Fatalf("expected ab, got %q", got)
+ }
+}
+
+func TestSelectionFromContext_Nil(t *testing.T) {
+ if got := selectionFromContext(nil); got != nil {
+ t.Fatalf("expected nil, got %v", got)
+ }
+}
+
+func TestSelectionFromContext_NoValue(t *testing.T) {
+ if got := selectionFromContext(context.Background()); got != nil {
+ t.Fatalf("expected nil, got %v", got)
+ }
+}
+
+func TestFilterJobsBySelection_Empty(t *testing.T) {
+ jobs := []cliJob{{index: 0, provider: "a"}}
+ filtered, err := filterJobsBySelection(jobs, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(filtered) != 1 {
+ t.Fatalf("expected original jobs, got %d", len(filtered))
+ }
+}