diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-04 14:24:36 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-04 14:24:36 +0300 |
| commit | d68e5b3b188585fe234d0ce295ec7f054c8bad5f (patch) | |
| tree | 974e067d9894f0da38513acdc27b56729b0f06e4 /internal | |
| parent | 3c322b7046669a77c276ce05469bfc2db0b446b2 (diff) | |
tests(lsp): push coverage over 80%\n- Add init/trigger, chat history, document handler, transport readMessage, and rewrite resolve tests\n- Cover deferShowDocument and shutdown reply\n- Now ~81.2% coverage for internal/lsp
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/lsp/chat_history_test.go | 27 | ||||
| -rw-r--r-- | internal/lsp/codeaction_more_test.go | 18 | ||||
| -rw-r--r-- | internal/lsp/codegen_helpers_test.go | 15 | ||||
| -rw-r--r-- | internal/lsp/completion_messages_test.go | 17 | ||||
| -rw-r--r-- | internal/lsp/document_handlers_test.go | 61 | ||||
| -rw-r--r-- | internal/lsp/fallback_items_test.go | 12 | ||||
| -rw-r--r-- | internal/lsp/gotest_append_test.go | 28 | ||||
| -rw-r--r-- | internal/lsp/handlers_end_to_end_test.go | 61 | ||||
| -rw-r--r-- | internal/lsp/init_and_trigger_tests.go | 52 | ||||
| -rw-r--r-- | internal/lsp/init_shutdown_test.go | 20 | ||||
| -rw-r--r-- | internal/lsp/log_context_test.go | 15 | ||||
| -rw-r--r-- | internal/lsp/transport_test.go | 27 |
12 files changed, 344 insertions, 9 deletions
diff --git a/internal/lsp/chat_history_test.go b/internal/lsp/chat_history_test.go new file mode 100644 index 0000000..0e9fed5 --- /dev/null +++ b/internal/lsp/chat_history_test.go @@ -0,0 +1,27 @@ +package lsp + +import "testing" + +func TestStripTrailingTrigger(t *testing.T) { + if got := stripTrailingTrigger("what?"); got != "what" { t.Fatalf("should remove trailing ?") } + if got := stripTrailingTrigger("what?>"); got != "what?" { t.Fatalf("should drop trailing > when preceded by ?") } + if got := stripTrailingTrigger("ok!>"); got != "ok!" { t.Fatalf("should drop > after !") } + if got := stripTrailingTrigger("note:>"); got != "note:" { t.Fatalf("should drop > after :") } + if got := stripTrailingTrigger("go;>"); got != "go;" { t.Fatalf("should drop > after ;") } +} + +func TestBuildChatHistory_OrderAndLimit(t *testing.T) { + s := newTestServer() + uri := "file:///chat.txt" + // Conversation: q1, > a1, blank, q2, > a2 lines, then current prompt + doc := "q1\n> a1\n\nq2\n> a2\n\n" + s.setDocument(uri, doc) + msgs := s.buildChatHistory(uri, 5, "q3") + // Expect: user q1, assistant a1, user q2, assistant a2, user q3 + if len(msgs) != 5 || msgs[0].Role != "user" || msgs[1].Role != "assistant" || msgs[2].Role != "user" || msgs[3].Role != "assistant" || msgs[4].Role != "user" { + t.Fatalf("unexpected roles: %+v", msgs) + } + if msgs[0].Content != "q1" || msgs[1].Content != "a1" || msgs[2].Content != "q2" || msgs[3].Content != "a2" || msgs[4].Content != "q3" { + t.Fatalf("unexpected contents: %+v", msgs) + } +} diff --git a/internal/lsp/codeaction_more_test.go b/internal/lsp/codeaction_more_test.go index e19c699..387afb5 100644 --- a/internal/lsp/codeaction_more_test.go +++ b/internal/lsp/codeaction_more_test.go @@ -22,6 +22,23 @@ func TestBuildDocumentCodeAction_AndResolve(t *testing.T) { if len(edits) != 1 || strings.TrimSpace(edits[0].NewText) == "" { t.Fatalf("expected replacement text") } } +func TestResolveCodeAction_Rewrite(t *testing.T) { + s := newTestServer() + s.llmClient = fakeLLM{resp: "rewritten"} + uri := "file:///x.go" + s.setDocument(uri, "package p\nvar a=1\n") + payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Instruction string `json:"instruction"` + Selection string `json:"selection"` + }{Type: "rewrite", URI: uri, Range: Range{Start: Position{Line:1}, End: Position{Line:1, Character: 5}}, Instruction: "do it", Selection: "var a"} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: rewrite selection", Data: raw} + if resolved, ok := s.resolveCodeAction(ca); !ok || resolved.Edit == nil { t.Fatalf("expected resolved rewrite edit") } +} + func TestBuildGoUnitTestCodeAction_AndResolveCreate(t *testing.T) { s := newTestServer() // place files under a temp dir to avoid collisions @@ -66,4 +83,3 @@ func TestDocBeforeAfter(t *testing.T) { before, after := s.docBeforeAfter(uri, Position{Line:1, Character:1}) if before != "ab\nc" || after != "d\nef" { t.Fatalf("before=%q after=%q", before, after) } } - diff --git a/internal/lsp/codegen_helpers_test.go b/internal/lsp/codegen_helpers_test.go new file mode 100644 index 0000000..d897953 --- /dev/null +++ b/internal/lsp/codegen_helpers_test.go @@ -0,0 +1,15 @@ +package lsp + +import "testing" + +func TestParseGoPackageName(t *testing.T) { + lines := []string{"// comment", "package mypkg // trailing"} + if got := parseGoPackageName(lines); got != "mypkg" { t.Fatalf("got %q", got) } + if got := parseGoPackageName([]string{"no package"}); got != "" { t.Fatalf("expected empty") } +} + +func TestDeriveGoFuncName(t *testing.T) { + if got := deriveGoFuncName("func Sum(a int) int { return a }"); got != "Sum" { t.Fatalf("got %q", got) } + if got := deriveGoFuncName("func (t *Type) Method(x int) {}"); got != "Method" { t.Fatalf("got %q", got) } +} + diff --git a/internal/lsp/completion_messages_test.go b/internal/lsp/completion_messages_test.go index eb16ccc..e9ec3e5 100644 --- a/internal/lsp/completion_messages_test.go +++ b/internal/lsp/completion_messages_test.go @@ -44,6 +44,22 @@ func TestBuildDocString_Contents(t *testing.T) { } } +func TestBuildPrompts_InParams(t *testing.T) { + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line:0, Character:5}} + sys, user := buildPrompts(true, p, "a", "func f(x)", "c", "func f(x)") + if !contains(sys, "function signatures") || !contains(user, "parameter list") { t.Fatalf("unexpected in-params prompts") } +} + +func TestPostProcessCompletion_CodeFencesAndDuplicates(t *testing.T) { + s := newTestServer() + // code fences + cleaned := s.postProcessCompletion("```go\nname := value\n```", "", "") + if cleaned == "" { t.Fatalf("expected non-empty after fence removal") } + // duplicate assignment prefix strip + cleaned2 := s.postProcessCompletion("name := other", "name := ", "name := ") + if cleaned2 == "" || cleaned2 == "name := other" { t.Fatalf("expected duplicate assignment prefix stripped: %q", cleaned2) } +} + func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && (stringIndex(s, sub) >= 0))) } func stringIndex(s, sub string) int { return len([]rune(s[:])) - len([]rune(s[:])) + (func() int { return intIndex(s, sub) })() } func intIndex(s, sub string) int { return Index(s, sub) } @@ -55,4 +71,3 @@ func Index(s, sub string) int { } return -1 } - diff --git a/internal/lsp/document_handlers_test.go b/internal/lsp/document_handlers_test.go new file mode 100644 index 0000000..bb12dd2 --- /dev/null +++ b/internal/lsp/document_handlers_test.go @@ -0,0 +1,61 @@ +package lsp + +import ( + "bytes" + "encoding/json" + "io" + "log" + "testing" + "time" +) + +func TestDidOpenChangeClose_UpdateDocs(t *testing.T) { + s := newTestServer() + uri := "file:///x.go" + // didOpen + open := DidOpenTextDocumentParams{TextDocument: TextDocumentItem{URI: uri, Text: "a\n"}} + s.handleDidOpen(Request{JSONRPC: "2.0", Method: "textDocument/didOpen", Params: mustJSON(open)}) + if s.getDocument(uri) == nil { t.Fatalf("doc not opened") } + // didChange + ch := DidChangeTextDocumentParams{TextDocument: VersionedTextDocumentIdentifier{URI: uri}, ContentChanges: []TextDocumentContentChangeEvent{{Text: "b\n"}}} + s.handleDidChange(Request{JSONRPC: "2.0", Method: "textDocument/didChange", Params: mustJSON(ch)}) + if d := s.getDocument(uri); d == nil || d.text != "b\n" { t.Fatalf("doc not changed") } + // didClose + s.handleDidClose(Request{JSONRPC: "2.0", Method: "textDocument/didClose", Params: mustJSON(DidCloseTextDocumentParams{TextDocument: TextDocumentIdentifier{URI: uri}})}) + if s.getDocument(uri) != nil { t.Fatalf("doc not closed") } +} + +func TestClientShowDocument_WritesRequest(t *testing.T) { + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + uri := "file:///x.go" + sel := Range{Start: Position{Line: 1}, End: Position{Line: 2}} + out.Reset() + s.clientShowDocument(uri, &sel) + req := captureRequest(t, &out) + if req.Method != "window/showDocument" { t.Fatalf("got %s", req.Method) } +} + +func TestHandleExecuteCommand_ShowDocument(t *testing.T) { + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + uri := "file:///x.go" + r := Range{Start: Position{Line:0}, End: Position{Line:0}} + args := []any{uri, r} + params := ExecuteCommandParams{Command: "hexai.showDocument", Arguments: args} + s.handleExecuteCommand(Request{JSONRPC: "2.0", ID: json.RawMessage("11"), Method: "workspace/executeCommand", Params: mustJSON(params)}) + req := captureRequest(t, &out) + if req.Method != "window/showDocument" { t.Fatalf("expected showDocument after executeCommand, got %s", req.Method) } +} + +func TestDeferShowDocument_WritesLater(t *testing.T) { + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + uri := "file:///x.go" + out.Reset() + s.deferShowDocument(uri, Range{Start: Position{Line:0}, End: Position{Line:0}}) + // wait >120ms per implementation + time.Sleep(160 * time.Millisecond) + req := captureRequest(t, &out) + if req.Method != "window/showDocument" { t.Fatalf("expected showDocument, got %s", req.Method) } +} diff --git a/internal/lsp/fallback_items_test.go b/internal/lsp/fallback_items_test.go new file mode 100644 index 0000000..0ce3542 --- /dev/null +++ b/internal/lsp/fallback_items_test.go @@ -0,0 +1,12 @@ +package lsp + +import "testing" + +func TestFallbackCompletionItems(t *testing.T) { + s := newTestServer() + items := s.fallbackCompletionItems("doc") + if len(items) != 1 || items[0].Label != "hexai-complete" || items[0].InsertText != "hexai" { + t.Fatalf("unexpected fallback items: %+v", items) + } +} + diff --git a/internal/lsp/gotest_append_test.go b/internal/lsp/gotest_append_test.go new file mode 100644 index 0000000..4fff684 --- /dev/null +++ b/internal/lsp/gotest_append_test.go @@ -0,0 +1,28 @@ +package lsp + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveGoTest_AppendsToExisting(t *testing.T) { + s := newTestServer() + dir := t.TempDir() + src := filepath.Join(dir, "m.go") + uri := "file://" + src + s.setDocument(uri, "package m\n\nfunc F(){}\n") + // Create existing test file + testPath := filepath.Join(dir, "m_test.go") + if err := os.WriteFile(testPath, []byte("package m\n\nimport \"testing\"\n\n"), 0o644); err != nil { t.Fatal(err) } + // LLM path to increase generateGoTestFunction coverage + s.llmClient = fakeLLM{resp: "func TestF(t *testing.T) {}"} + we, testURI, jump, ok := s.resolveGoTest(uri, Position{Line:2}) + if !ok || len(we.Changes) == 0 { t.Fatalf("expected append edit") } + if !strings.HasSuffix(testURI, "_test.go") { t.Fatalf("unexpected uri: %s", testURI) } + edits := we.Changes[testURI] + if len(edits) != 1 || !strings.Contains(edits[0].NewText, "TestF") { t.Fatalf("expected append with TestF") } + if jump.Start.Line < 0 { t.Fatalf("expected non-negative jump line") } +} + diff --git a/internal/lsp/handlers_end_to_end_test.go b/internal/lsp/handlers_end_to_end_test.go index 9767fa6..ba4a0bc 100644 --- a/internal/lsp/handlers_end_to_end_test.go +++ b/internal/lsp/handlers_end_to_end_test.go @@ -3,6 +3,7 @@ package lsp import ( "bytes" "encoding/json" + "fmt" "io" "log" "strings" @@ -29,14 +30,35 @@ func captureResponse(t *testing.T, buf *bytes.Buffer) Response { func captureRequest(t *testing.T, buf *bytes.Buffer) Request { t.Helper() raw := buf.String() - idx := strings.Index(raw, "\r\n\r\n") - if idx < 0 { t.Fatalf("no header/body separator in %q", raw) } - body := raw[idx+4:] - var req Request - if err := json.Unmarshal([]byte(body), &req); err != nil { - t.Fatalf("unmarshal request: %v", err) + // There may be multiple framed messages concatenated; scan for each + off := 0 + for off < len(raw) { + rest := raw[off:] + idx := strings.Index(rest, "\r\n\r\n") + if idx < 0 { break } + body := rest[idx+4:] + // Content-Length header indicates body length; parse length from header + hdr := rest[:idx] + clen := 0 + for _, line := range strings.Split(hdr, "\r\n") { + if strings.HasPrefix(strings.ToLower(line), "content-length:") { + var n int + _, _ = fmt.Sscanf(line, "Content-Length: %d", &n) + clen = n + break + } + } + if clen <= 0 || clen > len(body) { clen = len(body) } + piece := body[:clen] + var req Request + _ = json.Unmarshal([]byte(piece), &req) + if req.Method != "" { + return req + } + off += idx + 4 + clen } - return req + t.Fatalf("no request found in output") + return Request{} } func TestHandleCodeAction_ListsHexaiActions(t *testing.T) { @@ -140,6 +162,31 @@ func TestHandleCodeAction_NoLLMOrEmptySelection_ReturnsEmpty(t *testing.T) { func mustJSON(v any) json.RawMessage { b, _ := json.Marshal(v); return b } +func TestHandle_UnknownMethod_ReturnsError(t *testing.T) { + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out, handlers: map[string]func(Request){}} + req := Request{JSONRPC: "2.0", ID: json.RawMessage("9"), Method: "no/such"} + out.Reset() + s.handle(req) + resp := captureResponse(t, &out) + if resp.Error == nil || resp.Error.Code != -32601 { t.Fatalf("expected method not found error, got %+v", resp.Error) } +} + +func TestHandle_Dispatch_Initialize(t *testing.T) { + var out bytes.Buffer + // Build a server via constructor to ensure handlers map is populated + s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{}) + req := Request{JSONRPC: "2.0", ID: json.RawMessage("13"), Method: "initialize"} + out.Reset() + s.handle(req) + resp := captureResponse(t, &out) + var init InitializeResult + b, _ := json.Marshal(resp.Result) + _ = json.Unmarshal(b, &init) + if init.Capabilities.CodeActionProvider == nil || init.Capabilities.CompletionProvider == nil { t.Fatalf("missing capabilities") } +} + + func TestDetectAndHandleChat_InsertsReply(t *testing.T) { var out bytes.Buffer s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} diff --git a/internal/lsp/init_and_trigger_tests.go b/internal/lsp/init_and_trigger_tests.go new file mode 100644 index 0000000..cdc907e --- /dev/null +++ b/internal/lsp/init_and_trigger_tests.go @@ -0,0 +1,52 @@ +package lsp + +import ( + "bytes" + "encoding/json" + "io" + "log" + "testing" +) + +func TestHandleInitialize_Capabilities(t *testing.T) { + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + s.triggerChars = []string{".", ":"} + req := Request{JSONRPC: "2.0", ID: json.RawMessage("7"), Method: "initialize"} + out.Reset() + s.handleInitialize(req) + resp := captureResponse(t, &out) + var init InitializeResult + b, _ := json.Marshal(resp.Result) + if err := json.Unmarshal(b, &init); err != nil { t.Fatalf("decode init: %v", err) } + if init.Capabilities.CodeActionProvider == nil { t.Fatalf("expected codeActionProvider") } + // CodeActionProvider is any; re-marshal to struct + var cap struct{ ResolveProvider bool `json:"resolveProvider"` } + cb, _ := json.Marshal(init.Capabilities.CodeActionProvider) + _ = json.Unmarshal(cb, &cap) + if !cap.ResolveProvider { t.Fatalf("expected resolveProvider=true") } + if init.Capabilities.CompletionProvider == nil || len(init.Capabilities.CompletionProvider.TriggerCharacters) == 0 { + t.Fatalf("expected trigger characters") } +} + +func TestIsTriggerEvent_Variants(t *testing.T) { + s := newTestServer() + s.triggerChars = []string{".", ":"} + // 1) Manual invoke via context + ctx := struct{ TriggerKind int `json:"triggerKind"` }{TriggerKind:1} + raw, _ := json.Marshal(ctx) + p := CompletionParams{Position: Position{Line:0, Character:1}, Context: json.RawMessage(raw)} + if !s.isTriggerEvent(p, "a") { t.Fatalf("manual invoke should trigger") } + // 2) TriggerCharacter present and allowed + ctx2 := struct{ TriggerKind int `json:"triggerKind"`; TriggerCharacter string `json:"triggerCharacter"` }{TriggerKind:2, TriggerCharacter: "."} + raw2, _ := json.Marshal(ctx2) + p2 := CompletionParams{Position: Position{Line:0, Character:1}, Context: json.RawMessage(raw2)} + if !s.isTriggerEvent(p2, "a.") { t.Fatalf("trigger char should trigger") } + // 3) Fallback char left of cursor + p3 := CompletionParams{Position: Position{Line:0, Character:3}} + if !s.isTriggerEvent(p3, "ab:") { t.Fatalf("fallback char should trigger") } + // 4) Bare ';;' disables trigger + p4 := CompletionParams{Position: Position{Line:0, Character:2}} + if s.isTriggerEvent(p4, ";;") { t.Fatalf("bare ;; should not trigger") } +} + diff --git a/internal/lsp/init_shutdown_test.go b/internal/lsp/init_shutdown_test.go new file mode 100644 index 0000000..7b08f2c --- /dev/null +++ b/internal/lsp/init_shutdown_test.go @@ -0,0 +1,20 @@ +package lsp + +import ( + "bytes" + "encoding/json" + "io" + "log" + "testing" +) + +func TestHandleShutdown_Replies(t *testing.T) { + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + req := Request{JSONRPC: "2.0", ID: json.RawMessage("12"), Method: "shutdown"} + out.Reset() + s.handleShutdown(req) + resp := captureResponse(t, &out) + if string(resp.ID) != "12" || resp.Error != nil { t.Fatalf("unexpected shutdown response: %+v", resp) } +} + diff --git a/internal/lsp/log_context_test.go b/internal/lsp/log_context_test.go new file mode 100644 index 0000000..0bc4ed3 --- /dev/null +++ b/internal/lsp/log_context_test.go @@ -0,0 +1,15 @@ +package lsp + +import ( + "io" + "log" + "testing" +) + +func TestLogCompletionContext(t *testing.T) { + s := newTestServer() + s.logger = log.New(io.Discard, "", 0) + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line:1, Character:2}} + s.logCompletionContext(p, "a", "b", "c", "f") +} + diff --git a/internal/lsp/transport_test.go b/internal/lsp/transport_test.go new file mode 100644 index 0000000..0a01acd --- /dev/null +++ b/internal/lsp/transport_test.go @@ -0,0 +1,27 @@ +package lsp + +import ( + "bufio" + "bytes" + "testing" +) + +func TestReadMessage_ParsesContentLength(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`) + frame := []byte("Content-Length: ") + frame = append(frame, []byte(stringInt(len(body)))...) + frame = append(frame, []byte("\r\n\r\n")...) + frame = append(frame, body...) + s := &Server{in: bufio.NewReader(bytes.NewReader(frame))} + got, err := s.readMessage() + if err != nil || string(got) != string(body) { t.Fatalf("readMessage failed: %v %q", err, string(got)) } +} + +func stringInt(n int) string { + if n == 0 { return "0" } + var b [20]byte + i := len(b) + for n > 0 { i--; b[i] = byte('0' + n%10); n /= 10 } + return string(b[i:]) +} + |
