Files
filepuff-mcp/internal/query/format_test.go
T
lukaszraczylo 3195e353e2 fix(util): rune-safe truncation across formatters
firstLineOf (query) and truncateString (edit) sliced strings at a raw byte
offset, splitting a multibyte UTF-8 character into invalid bytes in
LLM-facing output (ast_query compact format and edit not-found errors are
hot paths). Extract the rune-boundary backoff already used by search's
truncateLine into util.RuneBoundary and route all three sites through it.

Adds table-driven tests for the primitive and a multibyte case for firstLineOf.
2026-06-20 00:44:38 +01:00

217 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package query
import (
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
// makeResults builds N dummy MatchResults.
func makeResults(n int) []MatchResult {
out := make([]MatchResult, n)
for i := range out {
out[i] = MatchResult{
File: "file.go",
Location: protocol.Location{
Line: i + 1,
Column: 1,
},
Text: "func Foo() {}\nline2",
}
}
return out
}
// TestFormatResultsVerboseDefault verifies verbose format includes code blocks (no preamble by default).
func TestFormatResultsVerboseDefault(t *testing.T) {
results := makeResults(2)
out := FormatResultsWithOptions(results, 0, "verbose", 0)
// v2 default: no preamble
if strings.Contains(out, "Found ") {
t.Errorf("v2 default should NOT emit preamble, got:\n%s", out)
}
if !strings.Contains(out, "```") {
t.Error("verbose mode should include code blocks")
}
}
// TestFormatResultsVerbosePreamble verifies verbose=true restores the preamble.
func TestFormatResultsVerbosePreamble(t *testing.T) {
results := makeResults(2)
out := FormatResultsWithOptions(results, 0, "verbose", 0, true)
if !strings.Contains(out, "Found 2 match(es):") {
t.Errorf("expected preamble with verbose=true, got:\n%s", out)
}
}
func TestFormatResultsCompact(t *testing.T) {
results := makeResults(3)
out := FormatResultsWithOptions(results, 0, "compact", 0)
// v2 default: no preamble in compact mode
// Should NOT have code blocks
if strings.Contains(out, "```") {
t.Error("compact mode should not have code blocks")
}
// Should have one line per match (beyond header)
lines := strings.Split(strings.TrimSpace(out), "\n")
// First two lines are "Found..." and blank, then 3 match lines
matchLines := 0
for _, l := range lines {
if strings.Contains(l, "file.go:") {
matchLines++
}
}
if matchLines != 3 {
t.Errorf("expected 3 match lines in compact mode, got %d\nOutput:\n%s", matchLines, out)
}
}
func TestFormatResultsLocation(t *testing.T) {
results := makeResults(3)
out := FormatResultsWithOptions(results, 0, "location", 0)
if strings.Contains(out, "```") {
t.Error("location mode should not have code blocks")
}
// Should be file:line only
for i := 1; i <= 3; i++ {
expected := "file.go:" + itoa(i)
if !strings.Contains(out, expected) {
t.Errorf("location output missing %s", expected)
}
}
}
func TestFormatResultsMaxResults(t *testing.T) {
results := makeResults(5)
out := FormatResultsWithOptions(results, 3, "verbose", 0)
// v2 default: no preamble — check that exactly 3 code blocks are present
codeBlockCount := strings.Count(out, "```")
if codeBlockCount != 6 { // 3 opening + 3 closing = 6
t.Errorf("expected 3 matches (6 backtick markers), got %d in:\n%s", codeBlockCount, out)
}
if !strings.Contains(out, "[remaining: 2]") {
t.Errorf("expected [remaining: 2] footer, got:\n%s", out)
}
}
func TestFormatResultsOffset(t *testing.T) {
results := makeResults(5)
// Skip first 2, show all remaining
out := FormatResultsWithOptions(results, 0, "verbose", 2)
// offset=2 from 5 results → 3 results; check 3 code blocks
codeBlockCount := strings.Count(out, "```")
if codeBlockCount != 6 {
t.Errorf("expected 3 matches (6 backtick markers) after offset=2, got %d in:\n%s", codeBlockCount, out)
}
}
func TestFormatResultsOffsetBeyondEnd(t *testing.T) {
results := makeResults(3)
out := FormatResultsWithOptions(results, 0, "verbose", 10)
if out != "No matches found." {
t.Errorf("expected 'No matches found.' for offset beyond end, got: %s", out)
}
}
func TestFormatResultsPaginationCursor(t *testing.T) {
// Offset=2, maxResults=2, 5 total → show items 3&4, remaining=1
results := makeResults(5)
out := FormatResultsWithOptions(results, 2, "verbose", 2)
// offset=2, maxResults=2 → items 3&4; check 2 code blocks
codeBlockCount := strings.Count(out, "```")
if codeBlockCount != 4 {
t.Errorf("expected 2 matches (4 backtick markers), got %d in:\n%s", codeBlockCount, out)
}
if !strings.Contains(out, "[remaining: 1]") {
t.Errorf("expected [remaining: 1], got:\n%s", out)
}
}
// FormatResultsWithCursor must emit the supplied cursor footer verbatim in place
// of the bare "[remaining: N]" hint when results are truncated.
func TestFormatResultsWithCursor(t *testing.T) {
results := makeResults(5)
cursor := "[cursor: abc123, remaining: 3]"
out := FormatResultsWithCursor(results, 2, "compact", 0, false, cursor)
if !strings.Contains(out, cursor) {
t.Errorf("expected cursor footer %q, got:\n%s", cursor, out)
}
if strings.Contains(out, "[remaining: 3]\n") {
t.Errorf("bare remaining hint should be replaced by cursor footer, got:\n%s", out)
}
// Empty cursorLine falls back to the bare hint (parity with FormatResultsWithOptions).
fallback := FormatResultsWithCursor(results, 2, "compact", 0, false, "")
if !strings.Contains(fallback, "[remaining: 3]") {
t.Errorf("empty cursorLine should fall back to [remaining: 3], got:\n%s", fallback)
}
// No truncation → no footer at all.
none := FormatResultsWithCursor(makeResults(2), 0, "compact", 0, false, cursor)
if strings.Contains(none, cursor) || strings.Contains(none, "[remaining:") {
t.Errorf("no truncation should emit no footer, got:\n%s", none)
}
}
func TestFormatResultsEmpty(t *testing.T) {
out := FormatResultsWithOptions(nil, 0, "verbose", 0)
if out != "No matches found." {
t.Errorf("expected 'No matches found.', got: %s", out)
}
}
func TestFormatResultsBackwardCompat(t *testing.T) {
// FormatResults wrapper should produce same output as FormatResultsWithOptions with verbose=false (default).
results := makeResults(2)
a := FormatResults(results, 0)
b := FormatResultsWithOptions(results, 0, "verbose", 0)
if a != b {
t.Error("FormatResults and FormatResultsWithOptions(verbose,0) should be identical")
}
// Both should have no preamble.
if strings.Contains(a, "Found ") {
t.Error("FormatResults should not emit preamble by default")
}
}
func TestFirstLineOf(t *testing.T) {
cases := []struct {
input string
maxLen int
want string
}{
{"hello world", 20, "hello world"},
{"line1\nline2", 20, "line1"},
{"\n\nfoo", 20, "foo"},
{"abcdefghij", 5, "abcd…"},
// Multibyte: "ααα" is 6 bytes (each α = 2). maxLen-1 = 3 lands inside the
// 2nd α; must back off to a rune boundary, never split into invalid UTF-8.
{"ααα", 4, "α…"},
}
for _, c := range cases {
got := firstLineOf(c.input, c.maxLen)
if got != c.want {
t.Errorf("firstLineOf(%q, %d) = %q, want %q", c.input, c.maxLen, got, c.want)
}
}
}
func itoa(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return strings.TrimRight(strings.TrimRight(
func() string {
buf := make([]byte, 20)
pos := 20
for n > 0 {
pos--
buf[pos] = byte('0' + n%10)
n /= 10
}
return string(buf[pos:])
}(), ""), "")
}