Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

664 lines
23 KiB
Go

package main
import (
"bytes"
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mattn/go-runewidth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/service"
)
func TestSessionSearchFlagValidation(t *testing.T) {
cmd := newSessionSearchCommand()
cmd.SetArgs([]string{"needle", "--regex", "--fts"})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
}
// TestSessionSearchSinceMutuallyExclusiveWithActiveSince verifies the error
// is returned before any service/DB access is attempted (no data dir is set
// up here), matching --regex/--fts's fail-fast validation style.
func TestSessionSearchSinceMutuallyExclusiveWithActiveSince(t *testing.T) {
cmd := newSessionSearchCommand()
cmd.SetArgs([]string{
"needle", "--since", "14d", "--active-since", "2024-01-01T00:00:00Z",
})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(),
"--since and --active-since are mutually exclusive")
}
func TestSessionSearchSinceRejectsInvalidFormat(t *testing.T) {
cmd := newSessionSearchCommand()
cmd.SetArgs([]string{"needle", "--since", "3x"})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid --since")
}
// seedSearchMessage inserts a single message for sessionID carrying content,
// so `session search <pattern>` has something to match.
func seedSearchMessage(t *testing.T, dataDir, sessionID, content string) {
t.Helper()
d, err := db.Open(filepath.Join(dataDir, "sessions.db"))
require.NoError(t, err)
t.Cleanup(func() { _ = d.Close() })
require.NoError(t, d.InsertMessages([]db.Message{{
SessionID: sessionID,
Ordinal: 1,
Role: "user",
Content: content,
ContentLength: len(content),
Timestamp: "2026-04-01T00:00:00Z",
}}))
}
// TestSessionSearchSinceFiltersByActivity is the end-to-end regression test
// for the CRITICAL requirement that --since actually narrows results by
// resolving to the same active_since window --active-since already
// threads through to the search filter: a session active within the
// window survives, one outside it does not.
func TestSessionSearchSinceFiltersByActivity(t *testing.T) {
dataDir := newAgentDataDir(t)
seedSessionsWithOpts(t, dataDir,
activitySeed("fresh", 2*time.Hour),
activitySeed("stale", 20*24*time.Hour),
)
seedSearchMessage(t, dataDir, "fresh", "needle in fresh session")
seedSearchMessage(t, dataDir, "stale", "needle in stale session")
out, err := executeCommand(newRootCommand(),
"session", "search", "needle", "--since", "7d", "--format", "json")
require.NoError(t, err)
got := decodeCLIJSON[service.ContentSearchResult](t, out)
require.Len(t, got.Matches, 1)
assert.Equal(t, "fresh", got.Matches[0].SessionID)
}
func TestSessionSearchFTSWithToolSource(t *testing.T) {
cmd := newSessionSearchCommand()
cmd.SetArgs([]string{"needle", "--fts", "--in", "tool_result"})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "messages only")
}
func TestResolveContentSearchModeMapping(t *testing.T) {
tests := []struct {
name string
useRegex, useFTS, useSemantic, useHybrid bool
wantMode string
}{
{name: "default substring", wantMode: "substring"},
{name: "regex", useRegex: true, wantMode: "regex"},
{name: "fts", useFTS: true, wantMode: "fts"},
{name: "semantic", useSemantic: true, wantMode: "semantic"},
{name: "hybrid", useHybrid: true, wantMode: "hybrid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mode, err := resolveContentSearchMode(
tt.useRegex, tt.useFTS, tt.useSemantic, tt.useHybrid, nil)
require.NoError(t, err)
assert.Equal(t, tt.wantMode, mode)
})
}
}
func TestResolveContentSearchModeMutualExclusion(t *testing.T) {
tests := []struct {
name string
useRegex, useFTS, useSemantic, useHybrid bool
}{
{name: "regex and fts", useRegex: true, useFTS: true},
{name: "semantic and hybrid", useSemantic: true, useHybrid: true},
{name: "regex and semantic", useRegex: true, useSemantic: true},
{name: "fts and hybrid", useFTS: true, useHybrid: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := resolveContentSearchMode(
tt.useRegex, tt.useFTS, tt.useSemantic, tt.useHybrid, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
})
}
}
func TestSessionSearchSemanticWithToolSource(t *testing.T) {
cmd := newSessionSearchCommand()
cmd.SetArgs([]string{"needle", "--semantic", "--in", "tool_input"})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "messages only")
}
func TestSessionSearchHybridWithToolSource(t *testing.T) {
cmd := newSessionSearchCommand()
cmd.SetArgs([]string{"needle", "--hybrid", "--in", "tool_result"})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "messages only")
}
// TestSessionSearchScopeRequiresSemanticOrHybrid verifies --scope fails
// fast (before any service/DB access) when set without --semantic/--hybrid.
func TestSessionSearchScopeRequiresSemanticOrHybrid(t *testing.T) {
for _, args := range [][]string{
{"needle", "--scope", "top"},
{"needle", "--fts", "--scope", "all"},
{"needle", "--regex", "--scope", "subordinate"},
} {
cmd := newSessionSearchCommand()
cmd.SetArgs(args)
err := cmd.Execute()
require.Error(t, err, "args %v", args)
assert.Contains(t, err.Error(), "--semantic or --hybrid", "args %v", args)
}
}
// TestSessionSearchScopeRejectsInvalidValue verifies the value gate fires
// at the CLI boundary rather than deep in the store.
func TestSessionSearchScopeRejectsInvalidValue(t *testing.T) {
cmd := newSessionSearchCommand()
cmd.SetArgs([]string{"needle", "--semantic", "--scope", "bogus"})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "top, all, or subordinate")
}
func TestValidateScopeFlag(t *testing.T) {
tests := []struct {
name string
scope string
useSemantic, useHybrid bool
wantErr string
}{
{name: "empty scope always valid"},
{name: "top with semantic", scope: "top", useSemantic: true},
{name: "all with hybrid", scope: "all", useHybrid: true},
{name: "subordinate with semantic", scope: "subordinate", useSemantic: true},
{name: "scope without mode flag", scope: "top",
wantErr: "--semantic or --hybrid"},
{name: "invalid value", scope: "bogus", useSemantic: true,
wantErr: "top, all, or subordinate"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateScopeFlag(tt.scope, tt.useSemantic, tt.useHybrid)
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestPrintContentMatchesHumanShowsScoreForScoredMatches(t *testing.T) {
score := 0.834
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{
SessionID: "sess1",
Project: "proj",
Location: "message",
Ordinal: 3,
Snippet: "hello world",
Score: &score,
},
{
SessionID: "sess2",
Project: "proj",
Location: "message",
Ordinal: 1,
Snippet: "no score here",
},
},
}
var buf bytes.Buffer
require.NoError(t, printContentMatchesHuman(&buf, res))
out := buf.String()
assert.Contains(t, out, "score=0.83")
lines := bytes.Split(buf.Bytes(), []byte("\n"))
require.NotEmpty(t, lines)
assert.NotContains(t, string(lines[2]), "score=",
"unscored match should not print a score")
}
func TestPrintContentMatchesHumanShowsContext(t *testing.T) {
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{
SessionID: "sess1", Project: "proj", Location: "message",
Ordinal: 5, Snippet: "the match line",
ContextBefore: []db.Message{
{Role: "user", Content: "earlier question"},
},
ContextAfter: []db.Message{
{Role: "assistant", Content: "later reply"},
},
},
},
}
var buf bytes.Buffer
require.NoError(t, printContentMatchesHuman(&buf, res))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 4)
assert.Equal(t, " user: earlier question", lines[0])
assert.Contains(t, lines[1], "sess1")
assert.Contains(t, lines[2], "the match line")
assert.Equal(t, " assistant: later reply", lines[3])
}
func TestPrintContentMatchesHumanTruncatesContextLine(t *testing.T) {
longContent := strings.Repeat("a", 250)
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{
SessionID: "sess1", Ordinal: 1, Snippet: "match",
ContextBefore: []db.Message{{Role: "user", Content: longContent}},
},
},
}
var buf bytes.Buffer
require.NoError(t, printContentMatchesHuman(&buf, res))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.NotEmpty(t, lines)
require.True(t, strings.HasPrefix(lines[0], " user: "))
body := strings.TrimPrefix(lines[0], " user: ")
assert.LessOrEqual(t, len([]rune(body)), 201)
assert.True(t, strings.HasSuffix(body, "…"))
}
func TestContentMatchJSONRoundTripsContext(t *testing.T) {
res := service.ContentSearchResult{
Matches: []db.ContentMatch{
{
SessionID: "sess1", Ordinal: 5,
ContextBefore: []db.Message{{Role: "user", Ordinal: 3, Content: "before"}},
ContextAfter: []db.Message{{Role: "assistant", Ordinal: 7, Content: "after"}},
},
{SessionID: "sess2", Ordinal: 1},
},
}
data, err := json.Marshal(res)
require.NoError(t, err)
assert.Contains(t, string(data), `"context_before"`)
assert.Contains(t, string(data), `"context_after"`)
var decoded service.ContentSearchResult
require.NoError(t, json.Unmarshal(data, &decoded))
require.Len(t, decoded.Matches, 2)
require.Len(t, decoded.Matches[0].ContextBefore, 1)
assert.Equal(t, "before", decoded.Matches[0].ContextBefore[0].Content)
assert.Empty(t, decoded.Matches[1].ContextBefore)
}
func TestContentMatchJSONRoundTripsScore(t *testing.T) {
score := 0.5
res := service.ContentSearchResult{
Matches: []db.ContentMatch{
{SessionID: "sess1", Ordinal: 1, Score: &score},
{SessionID: "sess2", Ordinal: 2},
},
}
data, err := json.Marshal(res)
require.NoError(t, err)
assert.Contains(t, string(data), `"score":0.5`)
var decoded service.ContentSearchResult
require.NoError(t, json.Unmarshal(data, &decoded))
require.Len(t, decoded.Matches, 2)
require.NotNil(t, decoded.Matches[0].Score)
assert.InDelta(t, score, *decoded.Matches[0].Score, 0.0001)
assert.Nil(t, decoded.Matches[1].Score)
}
// TestPrintContentMatchesHumanRendersUnitRangeAndSubMarker pins the human
// rendering for run-grouped semantic/hybrid hits: a multi-message unit
// renders "#<start>-<end> @<anchor>", a subordinate hit gains a "sub"
// marker, and a single-ordinal hit keeps today's plain "#<ordinal>" form.
func TestPrintContentMatchesHumanRendersUnitRangeAndSubMarker(t *testing.T) {
score := 0.91
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{
SessionID: "sess1", Project: "proj", Location: "message",
Ordinal: 19, OrdinalRange: [2]int{12, 40},
Subordinate: true, Score: &score, Snippet: "ranged hit",
},
{
SessionID: "sess2", Project: "proj", Location: "message",
Ordinal: 5, OrdinalRange: [2]int{5, 5},
Snippet: "single-message unit",
},
},
}
var buf bytes.Buffer
require.NoError(t, printContentMatchesHuman(&buf, res))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 4)
assert.Contains(t, lines[0], "#12-40 @19", "range with anchor marker")
assert.Contains(t, lines[0], " sub", "subordinate marker")
assert.Contains(t, lines[0], "score=0.91")
assert.Contains(t, lines[2], "#5", "single-ordinal hit keeps the plain form")
assert.NotContains(t, lines[2], "@", "no anchor marker for single-ordinal hits")
assert.NotContains(t, lines[2], " sub", "no subordinate marker for top-level hits")
}
// TestPrintContentMatchesTableBasic pins the flat (no --context) human
// rendering: a header row, one aligned row per match, the full session ID,
// the fused location:tool column, and an untruncated snippet when no
// terminal width is known (termWidth 0 — pipes, files, tests).
func TestPrintContentMatchesTableBasic(t *testing.T) {
longSnippet := strings.Repeat("s", 300)
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{
SessionID: "fc9367d6-38f7-4d18-863d-118dec238bd0",
Project: "yas", Location: "tool_result", ToolName: "Bash",
Ordinal: 12, Snippet: longSnippet,
},
{
SessionID: "sess2", Project: "proj2", Location: "message",
Ordinal: 3, Snippet: "line one\nline two",
},
},
}
var buf bytes.Buffer
require.NoError(t, printContentMatchesTable(&buf, res, 0))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 3)
header, row1, row2 := lines[0], lines[1], lines[2]
for _, col := range []string{"ID", "MATCH", "PROJECT", "LOCATION", "SNIPPET"} {
assert.Contains(t, header, col)
}
assert.NotContains(t, header, "SCORE",
"SCORE column omitted when no match is scored")
assert.Contains(t, row1, "fc9367d6-38f7-4d18-863d-118dec238bd0")
assert.Contains(t, row1, "#12")
assert.Contains(t, row1, "tool_result:Bash")
assert.Contains(t, row1, longSnippet, "snippet untruncated at width 0")
assert.Contains(t, row2, "line one line two",
"newlines collapsed to keep one row per match")
// Columns align: each header label starts at the same rune offset as
// the corresponding cell in every row.
idIdx := strings.Index(header, "ID")
matchIdx := strings.Index(header, "MATCH")
assert.Equal(t, idIdx, strings.Index(row1, "fc9367d6"))
assert.Equal(t, matchIdx, strings.Index(row1, "#12"))
assert.Equal(t, matchIdx, strings.Index(row2, "#3"))
}
// TestPrintContentMatchesTableScoreColumn pins the conditional SCORE
// column: present when any match carries a score, with an em dash for
// unscored rows.
func TestPrintContentMatchesTableScoreColumn(t *testing.T) {
score := 0.834
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{SessionID: "s1", Project: "p", Location: "message",
Ordinal: 3, Snippet: "hit", Score: &score},
{SessionID: "s2", Project: "p", Location: "message",
Ordinal: 1, Snippet: "hit"},
},
}
var buf bytes.Buffer
require.NoError(t, printContentMatchesTable(&buf, res, 0))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 3)
assert.Contains(t, lines[0], "SCORE")
assert.Contains(t, lines[1], "0.83")
assert.Contains(t, lines[2], emDash, "unscored row shows an em dash")
}
// TestPrintContentMatchesTableRangeAndSub pins the MATCH column for
// run-grouped hits: "#<start>-<end> @<anchor>" plus a "sub" marker for
// subordinate units.
func TestPrintContentMatchesTableRangeAndSub(t *testing.T) {
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{
SessionID: "s1", Project: "p", Location: "message",
Ordinal: 19, OrdinalRange: [2]int{12, 40},
Subordinate: true, Snippet: "ranged",
},
},
}
var buf bytes.Buffer
require.NoError(t, printContentMatchesTable(&buf, res, 0))
assert.Contains(t, buf.String(), "#12-40 @19 sub")
}
// TestPrintContentMatchesTableSnippetFillsWidth pins TTY behavior: the
// snippet expands to the remaining terminal width and is ellipsized there,
// so no row exceeds the terminal width.
func TestPrintContentMatchesTableSnippetFillsWidth(t *testing.T) {
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{SessionID: "s1", Project: "p", Location: "message",
Ordinal: 1, Snippet: strings.Repeat("x", 500)},
},
}
const width = 100
var buf bytes.Buffer
require.NoError(t, printContentMatchesTable(&buf, res, width))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 2)
row := lines[1]
assert.LessOrEqual(t, len([]rune(row)), width)
assert.True(t, strings.HasSuffix(row, "…"), "truncated snippet gains an ellipsis")
}
// TestPrintContentMatchesTableLocationCap pins the LOCATION cap: on a
// TTY a huge tool name cannot starve the snippet column, while non-TTY
// output (termWidth 0) keeps the full value, matching the untruncated
// snippet policy for pipes.
func TestPrintContentMatchesTableLocationCap(t *testing.T) {
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{SessionID: "s1", Project: "p", Location: "tool_result",
ToolName: strings.Repeat("t", 200), Ordinal: 1, Snippet: "hit"},
},
}
loc := "tool_result:" + strings.Repeat("t", 200)
var buf bytes.Buffer
require.NoError(t, printContentMatchesTable(&buf, res, 200))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 2)
assert.NotContains(t, lines[1], loc, "location is capped on a TTY")
assert.Contains(t, lines[1], "…")
assert.Contains(t, lines[1], "hit", "snippet survives a huge tool name")
buf.Reset()
require.NoError(t, printContentMatchesTable(&buf, res, 0))
lines = strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 2)
assert.Contains(t, lines[1], loc, "piped output keeps the full location")
}
// TestPrintContentMatchesTableEmptyAndCursor pins the unchanged empty
// message and pagination footer around the table.
func TestPrintContentMatchesTableEmptyAndCursor(t *testing.T) {
var buf bytes.Buffer
require.NoError(t, printContentMatchesTable(
&buf, &service.ContentSearchResult{}, 0))
assert.Equal(t, "(no matches)\n", buf.String())
buf.Reset()
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{SessionID: "s1", Project: "p", Location: "message",
Ordinal: 1, Snippet: "hit"},
},
NextCursor: 7,
}
require.NoError(t, printContentMatchesTable(&buf, res, 0))
assert.Contains(t, buf.String(), "More results: --cursor 7")
}
// TestContentSnippetBudget pins the snippet width computation: 0 means
// unknown terminal (untruncated), otherwise the remaining width after the
// fixed columns with a readability floor on narrow terminals.
func TestContentSnippetBudget(t *testing.T) {
tests := []struct {
name string
termWidth int
otherWidths []int
want int
}{
{"unknown terminal", 0, []int{10, 5}, 0},
{"wide terminal", 200, []int{36, 5, 4, 10}, 200 - (36 + 2) - (5 + 2) - (4 + 2) - (10 + 2)},
{"narrow terminal floors", 60, []int{36, 5, 4, 10}, contentSnippetMinWidth},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, contentSnippetBudget(tt.termWidth, tt.otherWidths))
})
}
}
// TestPrintContentSearchResultPicksRenderer pins the dispatch: --context
// requests keep the record-style output (context lines cannot live in
// table rows), while flat results render as a table.
func TestPrintContentSearchResultPicksRenderer(t *testing.T) {
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{
SessionID: "s1", Project: "p", Location: "message",
Ordinal: 5, Snippet: "the match",
ContextBefore: []db.Message{{Role: "user", Content: "before"}},
},
},
}
var buf bytes.Buffer
require.NoError(t, printContentSearchResult(&buf, res, 1))
assert.Contains(t, buf.String(), " user: before",
"context mode keeps record-style output")
assert.NotContains(t, buf.String(), "SNIPPET")
buf.Reset()
require.NoError(t, printContentSearchResult(&buf, res, 0))
assert.Contains(t, buf.String(), "SNIPPET", "flat mode renders the table")
}
// TestPrintContentMatchesTableSnippetExactFit pins the truncation boundary:
// a snippet that exactly fits the remaining width prints unmodified, with
// no rune dropped and no ellipsis.
func TestPrintContentMatchesTableSnippetExactFit(t *testing.T) {
const width = 100
// Fixed columns for this row: ID "s1" (header "ID" wins, 2) + MATCH
// "#1"/"MATCH" (5) + PROJECT "p"/"PROJECT" (7) + LOCATION
// "message"/"LOCATION" (8), each followed by a 2-space gap = 30 used,
// leaving a 70-rune snippet budget.
snippet := strings.Repeat("x", 70)
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{SessionID: "s1", Project: "p", Location: "message",
Ordinal: 1, Snippet: snippet},
},
}
var buf bytes.Buffer
require.NoError(t, printContentMatchesTable(&buf, res, width))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 2)
assert.True(t, strings.HasSuffix(lines[1], snippet),
"exact-fit snippet prints unmodified")
assert.Equal(t, width, len([]rune(lines[1])))
}
// TestPrintContentMatchesTableProjectCap pins the PROJECT cap: on a TTY
// an oversized or multi-line project name is collapsed and truncated so
// it cannot starve the snippet column, while non-TTY output keeps the
// full (whitespace-collapsed) value.
func TestPrintContentMatchesTableProjectCap(t *testing.T) {
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{SessionID: "s1", Project: strings.Repeat("p", 200) + "\nq",
Location: "message", Ordinal: 1, Snippet: "hit"},
},
}
var buf bytes.Buffer
require.NoError(t, printContentMatchesTable(&buf, res, 200))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 2)
assert.NotContains(t, lines[1], strings.Repeat("p", 200), "project is capped")
assert.NotContains(t, lines[1], "\nq", "project whitespace collapsed")
assert.Contains(t, lines[1], "…")
assert.Contains(t, lines[1], "hit", "snippet survives a huge project name")
assert.LessOrEqual(t,
strings.Index(lines[1], "hit"), 100,
"fixed columns stay bounded ahead of the snippet")
buf.Reset()
require.NoError(t, printContentMatchesTable(&buf, res, 0))
lines = strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 2)
assert.Contains(t, lines[1], strings.Repeat("p", 200)+" q",
"piped output keeps the full collapsed project")
}
// TestPrintContentMatchesTableWideRunesAlign pins display-width alignment:
// a project of full-width CJK runes occupies more terminal cells than its
// rune count, and the following column must still start at the same
// display column in every row.
func TestPrintContentMatchesTableWideRunesAlign(t *testing.T) {
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{SessionID: "s1", Project: "日本語", Location: "message",
Ordinal: 1, Snippet: "hit"},
{SessionID: "s2", Project: "ascii", Location: "message",
Ordinal: 2, Snippet: "hit"},
},
}
var buf bytes.Buffer
require.NoError(t, printContentMatchesTable(&buf, res, 0))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 3)
col := func(line string) int {
i := strings.Index(line, "message")
require.GreaterOrEqual(t, i, 0)
return runewidth.StringWidth(line[:i])
}
assert.Equal(t, col(lines[1]), col(lines[2]),
"LOCATION starts at the same display column despite wide runes")
}
// TestPrintContentMatchesTableWideSnippetBudget pins display-width
// truncation: a snippet of full-width runes must be cut so the whole row
// fits the terminal in display cells, not rune count.
func TestPrintContentMatchesTableWideSnippetBudget(t *testing.T) {
res := &service.ContentSearchResult{
Matches: []db.ContentMatch{
{SessionID: "s1", Project: "p", Location: "message",
Ordinal: 1, Snippet: strings.Repeat("界", 200)},
},
}
const width = 100
var buf bytes.Buffer
require.NoError(t, printContentMatchesTable(&buf, res, width))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 2)
assert.LessOrEqual(t, runewidth.StringWidth(lines[1]), width,
"row fits the terminal in display cells")
assert.True(t, strings.HasSuffix(lines[1], "…"))
}