mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
597 lines
15 KiB
Go
597 lines
15 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
|
|
maxLen int
|
|
expected string
|
|
}{
|
|
{
|
|
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 {
|
|
name string
|
|
obs *models.Observation
|
|
format string
|
|
expected SearchResult
|
|
}{
|
|
{
|
|
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 {
|
|
name string
|
|
summary *models.SessionSummary
|
|
format string
|
|
expected SearchResult
|
|
}{
|
|
{
|
|
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 {
|
|
name string
|
|
prompt *models.UserPromptWithSession
|
|
format string
|
|
expected SearchResult
|
|
}{
|
|
{
|
|
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
|
|
params SearchParams
|
|
expectedLimit int
|
|
expectedOrder string
|
|
}{
|
|
{
|
|
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])
|
|
})
|
|
}
|
|
}
|