mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
4f4b4ac70f
- [x] Add language-specific chunkers with AST parsing (Go, Python, TypeScript) - [x] Implement chunking manager to dispatch files to appropriate chunkers - [x] Integrate code chunks into vector sync for semantic search - [x] Add tree-sitter dependency for Python/TypeScript parsing - [x] Reorder struct fields for consistency across codebase - [x] Rename error variables to follow Go conventions (err → unmarshalErr, etc.) - [x] Add code chunk metadata to vector documents (language, symbol name, line ranges) - [x] Update worker service to initialize chunking pipeline with all three languages
1065 lines
29 KiB
Go
1065 lines
29 KiB
Go
// Package search provides unified search capabilities for claude-mnemonic.
|
|
package search
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
|
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
// ManagerSuite is a test suite for search Manager operations.
|
|
type ManagerSuite struct {
|
|
suite.Suite
|
|
}
|
|
|
|
func TestManagerSuite(t *testing.T) {
|
|
suite.Run(t, new(ManagerSuite))
|
|
}
|
|
|
|
// TestNewManager tests manager creation.
|
|
func (s *ManagerSuite) TestNewManager() {
|
|
// Test with nil stores (valid use case for testing)
|
|
m := NewManager(nil, nil, nil, nil)
|
|
s.NotNil(m)
|
|
s.Nil(m.observationStore)
|
|
s.Nil(m.summaryStore)
|
|
s.Nil(m.promptStore)
|
|
s.Nil(m.vectorClient)
|
|
}
|
|
|
|
// TestSearchParams tests SearchParams defaults.
|
|
func (s *ManagerSuite) TestSearchParams() {
|
|
params := SearchParams{
|
|
Query: "test query",
|
|
Project: "my-project",
|
|
Limit: 10,
|
|
}
|
|
|
|
s.Equal("test query", params.Query)
|
|
s.Equal("my-project", params.Project)
|
|
s.Equal(10, params.Limit)
|
|
s.Equal("", params.Type)
|
|
s.Equal("", params.OrderBy)
|
|
}
|
|
|
|
// TestSearchResult tests SearchResult struct.
|
|
func (s *ManagerSuite) TestSearchResult() {
|
|
result := SearchResult{
|
|
Type: "observation",
|
|
ID: 123,
|
|
Title: "Test Title",
|
|
Content: "Test content",
|
|
Project: "my-project",
|
|
Scope: "project",
|
|
CreatedAt: 1704067200000,
|
|
Score: 0.95,
|
|
Metadata: map[string]interface{}{
|
|
"obs_type": "discovery",
|
|
},
|
|
}
|
|
|
|
s.Equal("observation", result.Type)
|
|
s.Equal(int64(123), result.ID)
|
|
s.Equal("Test Title", result.Title)
|
|
s.Equal("Test content", result.Content)
|
|
s.Equal("my-project", result.Project)
|
|
s.Equal("project", result.Scope)
|
|
s.Equal(int64(1704067200000), result.CreatedAt)
|
|
s.Equal(0.95, result.Score)
|
|
s.Equal("discovery", result.Metadata["obs_type"])
|
|
}
|
|
|
|
// TestUnifiedSearchResult tests UnifiedSearchResult struct.
|
|
func (s *ManagerSuite) TestUnifiedSearchResult() {
|
|
result := UnifiedSearchResult{
|
|
Results: []SearchResult{
|
|
{Type: "observation", ID: 1},
|
|
{Type: "session", ID: 2},
|
|
},
|
|
TotalCount: 2,
|
|
Query: "test",
|
|
}
|
|
|
|
s.Len(result.Results, 2)
|
|
s.Equal(2, result.TotalCount)
|
|
s.Equal("test", result.Query)
|
|
}
|
|
|
|
// TestTruncate tests the truncate helper function.
|
|
func TestTruncate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
maxLen int
|
|
}{
|
|
{
|
|
name: "short string no truncation",
|
|
input: "hello",
|
|
maxLen: 10,
|
|
expected: "hello",
|
|
},
|
|
{
|
|
name: "exact length no truncation",
|
|
input: "hello",
|
|
maxLen: 5,
|
|
expected: "hello",
|
|
},
|
|
{
|
|
name: "long string truncated",
|
|
input: "hello world this is a long string",
|
|
maxLen: 10,
|
|
expected: "hello worl...",
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
maxLen: 10,
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "whitespace trimmed",
|
|
input: " hello ",
|
|
maxLen: 10,
|
|
expected: "hello",
|
|
},
|
|
{
|
|
name: "whitespace trimmed then truncated",
|
|
input: " hello world this is long ",
|
|
maxLen: 10,
|
|
expected: "hello worl...",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := truncate(tt.input, tt.maxLen)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestObservationToResult tests observation to result conversion.
|
|
func TestObservationToResult(t *testing.T) {
|
|
m := NewManager(nil, nil, nil, nil)
|
|
|
|
tests := []struct { //nolint:govet
|
|
obs *models.Observation
|
|
expected SearchResult
|
|
format string
|
|
name string
|
|
}{
|
|
{
|
|
name: "full format with all fields",
|
|
obs: &models.Observation{
|
|
ID: 123,
|
|
Project: "my-project",
|
|
Type: models.ObsTypeDiscovery,
|
|
Scope: models.ScopeProject,
|
|
Title: sql.NullString{String: "Test Title", Valid: true},
|
|
Narrative: sql.NullString{String: "Full narrative content", Valid: true},
|
|
CreatedAtEpoch: 1704067200000,
|
|
},
|
|
format: "full",
|
|
expected: SearchResult{
|
|
Type: "observation",
|
|
ID: 123,
|
|
Title: "Test Title",
|
|
Content: "Full narrative content",
|
|
Project: "my-project",
|
|
Scope: "project",
|
|
CreatedAt: 1704067200000,
|
|
},
|
|
},
|
|
{
|
|
name: "index format no content",
|
|
obs: &models.Observation{
|
|
ID: 456,
|
|
Project: "other-project",
|
|
Type: models.ObsTypeBugfix,
|
|
Scope: models.ScopeGlobal,
|
|
Title: sql.NullString{String: "Bug Fix", Valid: true},
|
|
Narrative: sql.NullString{String: "Narrative here", Valid: true},
|
|
CreatedAtEpoch: 1704067200000,
|
|
},
|
|
format: "index",
|
|
expected: SearchResult{
|
|
Type: "observation",
|
|
ID: 456,
|
|
Title: "Bug Fix",
|
|
Content: "", // Not included in index format
|
|
Project: "other-project",
|
|
Scope: "global",
|
|
CreatedAt: 1704067200000,
|
|
},
|
|
},
|
|
{
|
|
name: "null title",
|
|
obs: &models.Observation{
|
|
ID: 789,
|
|
Project: "project",
|
|
Type: models.ObsTypeFeature,
|
|
Scope: models.ScopeProject,
|
|
Title: sql.NullString{Valid: false},
|
|
Narrative: sql.NullString{Valid: false},
|
|
CreatedAtEpoch: 1704067200000,
|
|
},
|
|
format: "full",
|
|
expected: SearchResult{
|
|
Type: "observation",
|
|
ID: 789,
|
|
Title: "",
|
|
Content: "",
|
|
Project: "project",
|
|
Scope: "project",
|
|
CreatedAt: 1704067200000,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := m.observationToResult(tt.obs, tt.format)
|
|
assert.Equal(t, tt.expected.Type, result.Type)
|
|
assert.Equal(t, tt.expected.ID, result.ID)
|
|
assert.Equal(t, tt.expected.Title, result.Title)
|
|
assert.Equal(t, tt.expected.Content, result.Content)
|
|
assert.Equal(t, tt.expected.Project, result.Project)
|
|
assert.Equal(t, tt.expected.Scope, result.Scope)
|
|
assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSummaryToResult tests summary to result conversion.
|
|
func TestSummaryToResult(t *testing.T) {
|
|
m := NewManager(nil, nil, nil, nil)
|
|
|
|
tests := []struct { //nolint:govet
|
|
summary *models.SessionSummary
|
|
expected SearchResult
|
|
format string
|
|
name string
|
|
}{
|
|
{
|
|
name: "full format with all fields",
|
|
summary: &models.SessionSummary{
|
|
ID: 123,
|
|
Project: "my-project",
|
|
Request: sql.NullString{String: "Test request", Valid: true},
|
|
Learned: sql.NullString{String: "Learned this content", Valid: true},
|
|
CreatedAtEpoch: 1704067200000,
|
|
},
|
|
format: "full",
|
|
expected: SearchResult{
|
|
Type: "session",
|
|
ID: 123,
|
|
Title: "Test request",
|
|
Content: "Learned this content",
|
|
Project: "my-project",
|
|
CreatedAt: 1704067200000,
|
|
},
|
|
},
|
|
{
|
|
name: "index format no content",
|
|
summary: &models.SessionSummary{
|
|
ID: 456,
|
|
Project: "other-project",
|
|
Request: sql.NullString{String: "Another request", Valid: true},
|
|
Learned: sql.NullString{String: "Some learning", Valid: true},
|
|
CreatedAtEpoch: 1704067200000,
|
|
},
|
|
format: "index",
|
|
expected: SearchResult{
|
|
Type: "session",
|
|
ID: 456,
|
|
Title: "Another request",
|
|
Content: "", // Not included in index format
|
|
Project: "other-project",
|
|
CreatedAt: 1704067200000,
|
|
},
|
|
},
|
|
{
|
|
name: "long title truncated",
|
|
summary: &models.SessionSummary{
|
|
ID: 789,
|
|
Project: "project",
|
|
Request: sql.NullString{String: "This is a very long request that should be truncated because it exceeds the maximum allowed length for titles which is 100 characters", Valid: true},
|
|
Learned: sql.NullString{Valid: false},
|
|
CreatedAtEpoch: 1704067200000,
|
|
},
|
|
format: "full",
|
|
expected: SearchResult{
|
|
Type: "session",
|
|
ID: 789,
|
|
Title: "This is a very long request that should be truncated because it exceeds the maximum allowed length f...",
|
|
Content: "",
|
|
Project: "project",
|
|
CreatedAt: 1704067200000,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := m.summaryToResult(tt.summary, tt.format)
|
|
assert.Equal(t, tt.expected.Type, result.Type)
|
|
assert.Equal(t, tt.expected.ID, result.ID)
|
|
assert.Equal(t, tt.expected.Title, result.Title)
|
|
assert.Equal(t, tt.expected.Content, result.Content)
|
|
assert.Equal(t, tt.expected.Project, result.Project)
|
|
assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPromptToResult tests prompt to result conversion.
|
|
func TestPromptToResult(t *testing.T) {
|
|
m := NewManager(nil, nil, nil, nil)
|
|
|
|
tests := []struct { //nolint:govet
|
|
prompt *models.UserPromptWithSession
|
|
expected SearchResult
|
|
format string
|
|
name string
|
|
}{
|
|
{
|
|
name: "full format with content",
|
|
prompt: &models.UserPromptWithSession{
|
|
UserPrompt: models.UserPrompt{
|
|
ID: 123,
|
|
PromptText: "What is the meaning of life?",
|
|
CreatedAtEpoch: 1704067200000,
|
|
},
|
|
Project: "my-project",
|
|
},
|
|
format: "full",
|
|
expected: SearchResult{
|
|
Type: "prompt",
|
|
ID: 123,
|
|
Title: "What is the meaning of life?",
|
|
Content: "What is the meaning of life?",
|
|
Project: "my-project",
|
|
CreatedAt: 1704067200000,
|
|
},
|
|
},
|
|
{
|
|
name: "index format no content",
|
|
prompt: &models.UserPromptWithSession{
|
|
UserPrompt: models.UserPrompt{
|
|
ID: 456,
|
|
PromptText: "Short prompt",
|
|
CreatedAtEpoch: 1704067200000,
|
|
},
|
|
Project: "other-project",
|
|
},
|
|
format: "index",
|
|
expected: SearchResult{
|
|
Type: "prompt",
|
|
ID: 456,
|
|
Title: "Short prompt",
|
|
Content: "",
|
|
Project: "other-project",
|
|
CreatedAt: 1704067200000,
|
|
},
|
|
},
|
|
{
|
|
name: "long prompt truncated title",
|
|
prompt: &models.UserPromptWithSession{
|
|
UserPrompt: models.UserPrompt{
|
|
ID: 789,
|
|
PromptText: "This is a very long prompt that should be truncated because it exceeds the maximum allowed length for titles which is 100 characters and it keeps going",
|
|
CreatedAtEpoch: 1704067200000,
|
|
},
|
|
Project: "project",
|
|
},
|
|
format: "full",
|
|
expected: SearchResult{
|
|
Type: "prompt",
|
|
ID: 789,
|
|
Title: "This is a very long prompt that should be truncated because it exceeds the maximum allowed length fo...",
|
|
Content: "This is a very long prompt that should be truncated because it exceeds the maximum allowed length for titles which is 100 characters and it keeps going",
|
|
Project: "project",
|
|
CreatedAt: 1704067200000,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := m.promptToResult(tt.prompt, tt.format)
|
|
assert.Equal(t, tt.expected.Type, result.Type)
|
|
assert.Equal(t, tt.expected.ID, result.ID)
|
|
assert.Equal(t, tt.expected.Title, result.Title)
|
|
assert.Equal(t, tt.expected.Content, result.Content)
|
|
assert.Equal(t, tt.expected.Project, result.Project)
|
|
assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchParamsValidation tests parameter validation in UnifiedSearch.
|
|
func TestSearchParamsValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expectedOrder string
|
|
params SearchParams
|
|
expectedLimit int
|
|
}{
|
|
{
|
|
name: "default limit applied",
|
|
params: SearchParams{
|
|
Query: "test",
|
|
Project: "project",
|
|
Limit: 0,
|
|
},
|
|
expectedLimit: 20,
|
|
expectedOrder: "date_desc",
|
|
},
|
|
{
|
|
name: "negative limit corrected",
|
|
params: SearchParams{
|
|
Query: "test",
|
|
Project: "project",
|
|
Limit: -5,
|
|
},
|
|
expectedLimit: 20,
|
|
expectedOrder: "date_desc",
|
|
},
|
|
{
|
|
name: "limit over 100 capped",
|
|
params: SearchParams{
|
|
Query: "test",
|
|
Project: "project",
|
|
Limit: 200,
|
|
},
|
|
expectedLimit: 100,
|
|
expectedOrder: "date_desc",
|
|
},
|
|
{
|
|
name: "custom limit preserved",
|
|
params: SearchParams{
|
|
Query: "test",
|
|
Project: "project",
|
|
Limit: 50,
|
|
OrderBy: "relevance",
|
|
},
|
|
expectedLimit: 50,
|
|
expectedOrder: "relevance",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Since we can't easily call UnifiedSearch without stores,
|
|
// we verify the expected values through logic
|
|
params := tt.params
|
|
|
|
// Simulate the validation logic from UnifiedSearch
|
|
if params.Limit <= 0 {
|
|
params.Limit = 20
|
|
}
|
|
if params.Limit > 100 {
|
|
params.Limit = 100
|
|
}
|
|
if params.OrderBy == "" {
|
|
params.OrderBy = "date_desc"
|
|
}
|
|
|
|
assert.Equal(t, tt.expectedLimit, params.Limit)
|
|
assert.Equal(t, tt.expectedOrder, params.OrderBy)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDecisionsQueryBoost tests Decisions search query boosting.
|
|
func TestDecisionsQueryBoost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
inputQuery string
|
|
expectedQuery string
|
|
}{
|
|
{
|
|
name: "empty query not boosted",
|
|
inputQuery: "",
|
|
expectedQuery: "",
|
|
},
|
|
{
|
|
name: "query boosted with keywords",
|
|
inputQuery: "authentication",
|
|
expectedQuery: "authentication decision chose architecture",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
params := SearchParams{Query: tt.inputQuery}
|
|
// Simulate Decisions boost logic
|
|
if params.Query != "" {
|
|
params.Query = params.Query + " decision chose architecture"
|
|
}
|
|
assert.Equal(t, tt.expectedQuery, params.Query)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChangesQueryBoost tests Changes search query boosting.
|
|
func TestChangesQueryBoost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
inputQuery string
|
|
expectedQuery string
|
|
}{
|
|
{
|
|
name: "empty query not boosted",
|
|
inputQuery: "",
|
|
expectedQuery: "",
|
|
},
|
|
{
|
|
name: "query boosted with keywords",
|
|
inputQuery: "handler",
|
|
expectedQuery: "handler changed modified refactored",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
params := SearchParams{Query: tt.inputQuery}
|
|
// Simulate Changes boost logic
|
|
if params.Query != "" {
|
|
params.Query = params.Query + " changed modified refactored"
|
|
}
|
|
assert.Equal(t, tt.expectedQuery, params.Query)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHowItWorksQueryBoost tests HowItWorks search query boosting.
|
|
func TestHowItWorksQueryBoost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
inputQuery string
|
|
expectedQuery string
|
|
}{
|
|
{
|
|
name: "empty query not boosted",
|
|
inputQuery: "",
|
|
expectedQuery: "",
|
|
},
|
|
{
|
|
name: "query boosted with keywords",
|
|
inputQuery: "database",
|
|
expectedQuery: "database architecture design pattern implements",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
params := SearchParams{Query: tt.inputQuery}
|
|
// Simulate HowItWorks boost logic
|
|
if params.Query != "" {
|
|
params.Query = params.Query + " architecture design pattern implements"
|
|
}
|
|
assert.Equal(t, tt.expectedQuery, params.Query)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchTypeMapping tests type string to doc type mapping.
|
|
func TestSearchTypeMapping(t *testing.T) {
|
|
tests := []struct {
|
|
typeStr string
|
|
expected string
|
|
}{
|
|
{"observations", "observation"},
|
|
{"sessions", "session_summary"},
|
|
{"prompts", "user_prompt"},
|
|
{"", ""}, // Empty type for all
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run("type_"+tt.typeStr, func(t *testing.T) {
|
|
// This tests the type mapping logic
|
|
// Just verify the valid type strings
|
|
validTypes := map[string]bool{
|
|
"observations": true,
|
|
"sessions": true,
|
|
"prompts": true,
|
|
"": true,
|
|
}
|
|
assert.True(t, validTypes[tt.typeStr])
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFilterSearchWithObservations tests filter search when observations exist.
|
|
func TestFilterSearchWithObservations(t *testing.T) {
|
|
// Create mock observation
|
|
obs := &models.Observation{
|
|
ID: 1,
|
|
Project: "test-project",
|
|
Type: models.ObsTypeDiscovery,
|
|
Scope: models.ScopeProject,
|
|
Title: sql.NullString{String: "Test Title", Valid: true},
|
|
Narrative: sql.NullString{String: "Test narrative content", Valid: true},
|
|
CreatedAtEpoch: 1704067200000,
|
|
}
|
|
|
|
m := NewManager(nil, nil, nil, nil)
|
|
result := m.observationToResult(obs, "full")
|
|
|
|
assert.Equal(t, "observation", result.Type)
|
|
assert.Equal(t, int64(1), result.ID)
|
|
assert.Equal(t, "Test Title", result.Title)
|
|
assert.Equal(t, "Test narrative content", result.Content)
|
|
assert.Equal(t, "test-project", result.Project)
|
|
assert.Equal(t, "project", result.Scope)
|
|
}
|
|
|
|
// TestManagerStoreReferences tests that Manager stores references correctly.
|
|
func TestManagerStoreReferences(t *testing.T) {
|
|
m := NewManager(nil, nil, nil, nil)
|
|
|
|
assert.Nil(t, m.observationStore)
|
|
assert.Nil(t, m.summaryStore)
|
|
assert.Nil(t, m.promptStore)
|
|
assert.Nil(t, m.vectorClient)
|
|
}
|
|
|
|
// TestObservationToResultWithMetadata tests metadata inclusion in results.
|
|
func TestObservationToResultWithMetadata(t *testing.T) {
|
|
m := NewManager(nil, nil, nil, nil)
|
|
|
|
tests := []struct {
|
|
name string
|
|
obsType models.ObservationType
|
|
scope models.ObservationScope
|
|
}{
|
|
{"bugfix_project", models.ObsTypeBugfix, models.ScopeProject},
|
|
{"feature_global", models.ObsTypeFeature, models.ScopeGlobal},
|
|
{"discovery_project", models.ObsTypeDiscovery, models.ScopeProject},
|
|
{"decision_global", models.ObsTypeDecision, models.ScopeGlobal},
|
|
{"refactor_project", models.ObsTypeRefactor, models.ScopeProject},
|
|
{"change_global", models.ObsTypeChange, models.ScopeGlobal},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
obs := &models.Observation{
|
|
ID: 1,
|
|
Project: "test-project",
|
|
Type: tt.obsType,
|
|
Scope: tt.scope,
|
|
Title: sql.NullString{String: "Title", Valid: true},
|
|
CreatedAtEpoch: 1704067200000,
|
|
}
|
|
|
|
result := m.observationToResult(obs, "full")
|
|
|
|
assert.Equal(t, string(tt.obsType), result.Metadata["obs_type"])
|
|
assert.Equal(t, string(tt.scope), result.Metadata["scope"])
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSummaryToResultTruncation tests title truncation in summary results.
|
|
func TestSummaryToResultTruncation(t *testing.T) {
|
|
m := NewManager(nil, nil, nil, nil)
|
|
|
|
tests := []struct {
|
|
name string
|
|
request string
|
|
expectedLen int
|
|
shouldTrunc bool
|
|
}{
|
|
{"short_title", "Short request", 13, false},
|
|
{"exact_100", string(make([]byte, 100)), 103, true}, // 100 + "..."
|
|
{"over_100", string(make([]byte, 150)), 103, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
summary := &models.SessionSummary{
|
|
ID: 1,
|
|
Project: "test-project",
|
|
Request: sql.NullString{String: tt.request, Valid: true},
|
|
CreatedAtEpoch: 1704067200000,
|
|
}
|
|
|
|
result := m.summaryToResult(summary, "full")
|
|
|
|
if tt.shouldTrunc {
|
|
assert.LessOrEqual(t, len(result.Title), tt.expectedLen)
|
|
assert.True(t, len(result.Title) <= 103) // max 100 + "..."
|
|
} else {
|
|
assert.Equal(t, tt.request, result.Title)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPromptToResultFormats tests prompt to result conversion with different formats.
|
|
func TestPromptToResultFormats(t *testing.T) {
|
|
m := NewManager(nil, nil, nil, nil)
|
|
|
|
prompt := &models.UserPromptWithSession{
|
|
UserPrompt: models.UserPrompt{
|
|
ID: 123,
|
|
PromptText: "What is the meaning of life?",
|
|
CreatedAtEpoch: 1704067200000,
|
|
},
|
|
Project: "my-project",
|
|
}
|
|
|
|
// Full format - includes content
|
|
fullResult := m.promptToResult(prompt, "full")
|
|
assert.Equal(t, "What is the meaning of life?", fullResult.Content)
|
|
|
|
// Index format - no content
|
|
indexResult := m.promptToResult(prompt, "index")
|
|
assert.Equal(t, "", indexResult.Content)
|
|
|
|
// Both should have same title
|
|
assert.Equal(t, fullResult.Title, indexResult.Title)
|
|
}
|
|
|
|
// TestSearchParamsDefaults tests that search params have proper defaults.
|
|
func TestSearchParamsDefaults(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
initialOrder string
|
|
expectedOrder string
|
|
initialLimit int
|
|
expectedLimit int
|
|
}{
|
|
{name: "zero_limit", initialLimit: 0, initialOrder: "", expectedLimit: 20, expectedOrder: "date_desc"},
|
|
{name: "negative_limit", initialLimit: -5, initialOrder: "", expectedLimit: 20, expectedOrder: "date_desc"},
|
|
{name: "over_100_limit", initialLimit: 150, initialOrder: "", expectedLimit: 100, expectedOrder: "date_desc"},
|
|
{name: "valid_limit_50", initialLimit: 50, initialOrder: "relevance", expectedLimit: 50, expectedOrder: "relevance"},
|
|
{name: "custom_order", initialLimit: 30, initialOrder: "date_asc", expectedLimit: 30, expectedOrder: "date_asc"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
params := SearchParams{
|
|
Limit: tt.initialLimit,
|
|
OrderBy: tt.initialOrder,
|
|
}
|
|
|
|
// Simulate the normalization that happens in UnifiedSearch
|
|
if params.Limit <= 0 {
|
|
params.Limit = 20
|
|
}
|
|
if params.Limit > 100 {
|
|
params.Limit = 100
|
|
}
|
|
if params.OrderBy == "" {
|
|
params.OrderBy = "date_desc"
|
|
}
|
|
|
|
assert.Equal(t, tt.expectedLimit, params.Limit)
|
|
assert.Equal(t, tt.expectedOrder, params.OrderBy)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTruncateEdgeCases tests edge cases for truncate function.
|
|
func TestTruncateEdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
maxLen int
|
|
}{
|
|
// Unicode strings - uses byte length so ensure maxLen accommodates full string
|
|
{name: "unicode_string_no_truncate", input: "日本語テスト", maxLen: 20, expected: "日本語テスト"},
|
|
{name: "mixed_unicode_no_truncate", input: "Hello世界", maxLen: 15, expected: "Hello世界"},
|
|
// ASCII truncation
|
|
{name: "ascii_truncate", input: "Hello World", maxLen: 5, expected: "Hello..."},
|
|
{name: "only_whitespace", input: " ", maxLen: 10, expected: ""},
|
|
{name: "tabs_and_newlines", input: "\t\n \t", maxLen: 10, expected: ""},
|
|
{name: "newlines_with_content", input: "\n\nhello\n\n", maxLen: 10, expected: "hello"},
|
|
{name: "zero_max_len", input: "hello", maxLen: 0, expected: "..."},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := truncate(tt.input, tt.maxLen)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUnifiedSearchResultEmpty tests empty UnifiedSearchResult.
|
|
func TestUnifiedSearchResultEmpty(t *testing.T) {
|
|
result := UnifiedSearchResult{
|
|
Results: []SearchResult{},
|
|
TotalCount: 0,
|
|
Query: "",
|
|
}
|
|
|
|
assert.Empty(t, result.Results)
|
|
assert.Equal(t, 0, result.TotalCount)
|
|
assert.Equal(t, "", result.Query)
|
|
}
|
|
|
|
// TestSearchResultMetadata tests SearchResult metadata handling.
|
|
func TestSearchResultMetadata(t *testing.T) {
|
|
result := SearchResult{
|
|
Metadata: map[string]interface{}{
|
|
"obs_type": "discovery",
|
|
"scope": "project",
|
|
"count": 42,
|
|
"enabled": true,
|
|
},
|
|
}
|
|
|
|
assert.Equal(t, "discovery", result.Metadata["obs_type"])
|
|
assert.Equal(t, "project", result.Metadata["scope"])
|
|
assert.Equal(t, 42, result.Metadata["count"])
|
|
assert.Equal(t, true, result.Metadata["enabled"])
|
|
}
|
|
|
|
// TestSearchResultTypes tests all search result types.
|
|
func TestSearchResultTypes(t *testing.T) {
|
|
types := []string{"observation", "session", "prompt"}
|
|
|
|
for _, typ := range types {
|
|
t.Run(typ, func(t *testing.T) {
|
|
result := SearchResult{
|
|
Type: typ,
|
|
}
|
|
assert.Equal(t, typ, result.Type)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchParamsAllFields tests SearchParams with all fields populated.
|
|
func TestSearchParamsAllFields(t *testing.T) {
|
|
params := SearchParams{
|
|
Query: "authentication bug",
|
|
Type: "observations",
|
|
Project: "my-project",
|
|
ObsType: "bugfix",
|
|
Concepts: "security,auth",
|
|
Files: "handler.go,auth.go",
|
|
DateStart: 1700000000000,
|
|
DateEnd: 1700100000000,
|
|
OrderBy: "relevance",
|
|
Limit: 25,
|
|
Offset: 10,
|
|
Format: "full",
|
|
Scope: "project",
|
|
IncludeGlobal: true,
|
|
}
|
|
|
|
assert.Equal(t, "authentication bug", params.Query)
|
|
assert.Equal(t, "observations", params.Type)
|
|
assert.Equal(t, "my-project", params.Project)
|
|
assert.Equal(t, "bugfix", params.ObsType)
|
|
assert.Equal(t, "security,auth", params.Concepts)
|
|
assert.Equal(t, "handler.go,auth.go", params.Files)
|
|
assert.Equal(t, int64(1700000000000), params.DateStart)
|
|
assert.Equal(t, int64(1700100000000), params.DateEnd)
|
|
assert.Equal(t, "relevance", params.OrderBy)
|
|
assert.Equal(t, 25, params.Limit)
|
|
assert.Equal(t, 10, params.Offset)
|
|
assert.Equal(t, "full", params.Format)
|
|
assert.Equal(t, "project", params.Scope)
|
|
assert.True(t, params.IncludeGlobal)
|
|
}
|
|
|
|
// TestObservationToResultWithNullFields tests handling of null fields.
|
|
func TestObservationToResultWithNullFields(t *testing.T) {
|
|
m := NewManager(nil, nil, nil, nil)
|
|
|
|
obs := &models.Observation{
|
|
ID: 1,
|
|
Project: "test-project",
|
|
Type: models.ObsTypeDiscovery,
|
|
Scope: models.ScopeProject,
|
|
Title: sql.NullString{Valid: false},
|
|
Narrative: sql.NullString{Valid: false},
|
|
CreatedAtEpoch: 1704067200000,
|
|
}
|
|
|
|
result := m.observationToResult(obs, "full")
|
|
|
|
assert.Equal(t, "", result.Title)
|
|
assert.Equal(t, "", result.Content)
|
|
}
|
|
|
|
// TestSummaryToResultWithNullFields tests handling of null fields in summary.
|
|
func TestSummaryToResultWithNullFields(t *testing.T) {
|
|
m := NewManager(nil, nil, nil, nil)
|
|
|
|
summary := &models.SessionSummary{
|
|
ID: 1,
|
|
Project: "test-project",
|
|
Request: sql.NullString{Valid: false},
|
|
Learned: sql.NullString{Valid: false},
|
|
CreatedAtEpoch: 1704067200000,
|
|
}
|
|
|
|
result := m.summaryToResult(summary, "full")
|
|
|
|
assert.Equal(t, "", result.Title)
|
|
assert.Equal(t, "", result.Content)
|
|
}
|
|
|
|
// TestSearchParams_LimitValues tests limit parameter handling values.
|
|
func TestSearchParams_LimitValues(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
inputLimit int
|
|
expectedValid bool
|
|
}{
|
|
{"zero_limit", 0, true},
|
|
{"negative_limit", -5, true},
|
|
{"normal_limit", 20, true},
|
|
{"max_limit", 100, true},
|
|
{"over_limit", 200, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
params := SearchParams{
|
|
Query: "test",
|
|
Project: "test",
|
|
Limit: tt.inputLimit,
|
|
}
|
|
assert.NotNil(t, params)
|
|
assert.Equal(t, tt.inputLimit, params.Limit)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchParams_OrderByValues tests order by parameter values.
|
|
func TestSearchParams_OrderByValues(t *testing.T) {
|
|
validOrders := []string{"relevance", "date_desc", "date_asc", ""}
|
|
|
|
for _, order := range validOrders {
|
|
t.Run("order_"+order, func(t *testing.T) {
|
|
params := SearchParams{
|
|
Query: "test", //nolint:govet
|
|
Project: "test", //nolint:govet
|
|
OrderBy: order,
|
|
}
|
|
assert.Equal(t, order, params.OrderBy)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchParams_TypeValues tests type parameter values.
|
|
func TestSearchParams_TypeValues(t *testing.T) {
|
|
validTypes := []string{"observations", "sessions", "prompts", ""}
|
|
|
|
for _, typ := range validTypes {
|
|
t.Run("type_"+typ, func(t *testing.T) {
|
|
params := SearchParams{
|
|
Type: typ,
|
|
}
|
|
assert.Equal(t, typ, params.Type)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchParams_ScopeValues tests scope parameter values.
|
|
func TestSearchParams_ScopeValues(t *testing.T) {
|
|
validScopes := []string{"project", "global", ""}
|
|
|
|
for _, scope := range validScopes {
|
|
t.Run("scope_"+scope, func(t *testing.T) {
|
|
params := SearchParams{
|
|
Query: "test", //nolint:govet
|
|
Project: "test", //nolint:govet
|
|
Scope: scope,
|
|
}
|
|
assert.Equal(t, scope, params.Scope)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchParams_FormatValues tests format parameter values.
|
|
func TestSearchParams_FormatValues(t *testing.T) {
|
|
validFormats := []string{"index", "full", ""}
|
|
|
|
for _, format := range validFormats {
|
|
t.Run("format_"+format, func(t *testing.T) {
|
|
params := SearchParams{
|
|
Query: "test", //nolint:govet
|
|
Project: "test", //nolint:govet
|
|
Format: format,
|
|
}
|
|
assert.Equal(t, format, params.Format)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUnifiedSearchResult_MultipleResults tests result with multiple items.
|
|
func TestUnifiedSearchResult_MultipleResults(t *testing.T) {
|
|
results := []SearchResult{
|
|
{Type: "observation", ID: 1, Title: "First", Project: "test"},
|
|
{Type: "session", ID: 2, Title: "Second", Project: "test"},
|
|
{Type: "prompt", ID: 3, Title: "Third", Project: "test"},
|
|
}
|
|
|
|
result := UnifiedSearchResult{
|
|
Results: results,
|
|
TotalCount: 3,
|
|
Query: "test query", //nolint:govet
|
|
}
|
|
|
|
assert.Len(t, result.Results, 3)
|
|
assert.Equal(t, 3, result.TotalCount)
|
|
assert.Equal(t, "observation", result.Results[0].Type)
|
|
assert.Equal(t, "session", result.Results[1].Type)
|
|
assert.Equal(t, "prompt", result.Results[2].Type)
|
|
}
|
|
|
|
// TestSearchResult_Metadata tests metadata handling in SearchResult.
|
|
func TestSearchResult_Metadata(t *testing.T) {
|
|
metadata := map[string]interface{}{
|
|
"obs_type": "discovery",
|
|
"concepts": []string{"auth", "security"},
|
|
"files_count": 5,
|
|
"is_important": true,
|
|
}
|
|
|
|
result := SearchResult{
|
|
Type: "observation", //nolint:govet
|
|
ID: 1, //nolint:govet
|
|
Metadata: metadata,
|
|
}
|
|
|
|
assert.Equal(t, "discovery", result.Metadata["obs_type"])
|
|
assert.Equal(t, 5, result.Metadata["files_count"])
|
|
assert.Equal(t, true, result.Metadata["is_important"])
|
|
}
|
|
|
|
// TestSearchResult_Scores tests score handling in SearchResult.
|
|
func TestSearchResult_Scores(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
score float64
|
|
}{
|
|
{"perfect_score", 1.0},
|
|
{"high_score", 0.95},
|
|
{"medium_score", 0.5},
|
|
{"low_score", 0.1},
|
|
{"zero_score", 0.0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := SearchResult{
|
|
Score: tt.score,
|
|
}
|
|
assert.Equal(t, tt.score, result.Score)
|
|
})
|
|
}
|
|
}
|