* refactor(tuning): prune dormant runtime knobs Remove unused tuning controls and adaptive search budget state. Extract testable ONNX slot reconciliation and search telemetry hydration seams, and correct the Apple Silicon sysctl value width. * refactor(vector): retire dormant backends Remove unused FAISS, model-infrastructure, VectorIndexManager, and TurboQuant paths while keeping Simeon PQ and float-vector storage. Migrate unused V2.2 quantized schemas transactionally to V2.3. Databases containing non-default quantized formats or packed-code blobs fail closed with recovery guidance and no mutation. * refactor(plugins): remove legacy SDK tooling Remove the Ghidra and SDK submodules, their bespoke tests, and stale scan exclusions. Retain generic PluginProcess coverage against the repository mock fixture. * build(vector): remove stale FAISS overrides * refactor(ipc): remove dormant FSM snapshots * refactor(ingest): unify bounded KG enqueue * fix(plugins): harden allocation contracts Keep ABI outputs null or fully initialized when allocations fail, and represent empty object payloads without relying on malloc(0). * fix(storage): define empty object reads Return a null buffer with zero length for successful empty-object reads instead of treating malloc(0) as an allocation contract. * refactor(storage): trim through string views * test: prune dormant harness helpers Remove unused readiness and fixture helpers, and keep benchmark client-count doubling in a wide integer domain. * chore(cpp): initialize analyzer-facing locals * build(simeon): advance analyzer cleanup * refactor(config): drop dead daemon client keys * refactor(ipc): inject daemon client transport Socket and in-process execution now share DaemonClient request dispatch, so CLI fallbacks do not need alternate service paths. * refactor(retrieval): route CLI through daemon Search and grep now use the same daemon handlers over socket or in-process transport. Live grep remains an explicit freshness inspection. * refactor(graph): move CLI scope into daemon Graph CLI requests now share daemon handlers across socket and in-process transports. CWD filtering occurs before pagination so scoped totals and pages stay correct. * build: isolate test-only runtime controls * refactor(metadata): decouple lock telemetry * fix(services): return on request timeout * fix(retrieval): enforce workspace scope * fix(graph): scope nodes before pagination * fix(grep): drop binary result lines * fix(daemon): bound checkpoint wait * fix(repair): use KG health checks * fix(storage): always verify compressed CRC * perf(vector): skip unchanged PQ snapshots * build(zyp): exclude bench from default build * build: protect Meson files from formatter * refactor: remove unreachable production APIs * refactor(daemon): remove dormant runtime state * fix(vector): harden daemon provider calls Timed-out coroutines must own their client and requests. Provider discovery must also fail instead of returning synthetic embeddings in production. * refactor(storage): remove unusable adapters * fix(cli): unify in-process add requests * fix(tsan): preserve async worker progress * test(cli): isolate daemon-dependent suites * fix(repair): expose queue backoff progress
2055 lines
65 KiB
C++
2055 lines
65 KiB
C++
/**
|
|
* @file hybrid_search_comprehensive_test.cpp
|
|
* @brief Integration tests for SearchEngine (PBI-071, updated for PBI-091)
|
|
*
|
|
* Integration test coverage focusing on:
|
|
* - Multi-component search (FTS5, vector, symbol, KG, path tree)
|
|
* - Fusion strategies (WEIGHTED_SUM, RECIPROCAL_RANK, WEIGHTED_RECIPROCAL, COMB_MNZ)
|
|
* - Fallback scenarios (degraded mode, timeouts)
|
|
* - Real component integration (actual indexes, engines)
|
|
* - Performance characteristics
|
|
*
|
|
* Note: This is an integration test, not a unit test. It uses real
|
|
* components rather than mocks to validate end-to-end behavior.
|
|
*/
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
|
#include "../../common/test_helpers_catch2.h"
|
|
|
|
#include <yams/compat/unistd.h>
|
|
|
|
#if defined(_WIN32) && __has_include(<onnxruntime_c_api.h>)
|
|
#include <onnxruntime_c_api.h>
|
|
#define YAMS_ORT_API_VERSION ORT_API_VERSION
|
|
#else
|
|
#define YAMS_ORT_API_VERSION 0
|
|
#endif
|
|
|
|
#if defined(_WIN32) && YAMS_ORT_API_VERSION < 23
|
|
|
|
TEST_CASE("HybridSearch - Windows ONNX Runtime API too old", "[hybrid][windows][skip]") {
|
|
SUCCEED("Skipping hybrid search tests on Windows: ONNX Runtime API version below 23.");
|
|
}
|
|
|
|
#else
|
|
|
|
#define SKIP_HYBRID_ON_WINDOWS() ((void)0)
|
|
|
|
#include <yams/api/content_store_builder.h>
|
|
#include <yams/core/types.h>
|
|
#include <yams/metadata/connection_pool.h>
|
|
#include <yams/metadata/database.h>
|
|
#include <yams/metadata/metadata_repository.h>
|
|
#include <yams/metadata/migration.h>
|
|
#include <yams/search/search_engine.h>
|
|
#include <yams/search/search_engine_builder.h>
|
|
#include <yams/vector/embedding_generator.h>
|
|
#include <yams/vector/vector_database.h>
|
|
|
|
#include <boost/asio/awaitable.hpp>
|
|
#include <boost/asio/co_spawn.hpp>
|
|
#include <boost/asio/executor_work_guard.hpp>
|
|
#include <boost/asio/io_context.hpp>
|
|
#include <boost/asio/use_future.hpp>
|
|
|
|
#include <yams/app/services/services.hpp>
|
|
|
|
#include <spdlog/spdlog.h>
|
|
|
|
#include "common/fixture_manager.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <future>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
using namespace yams;
|
|
using namespace yams::app::services;
|
|
using namespace yams::metadata;
|
|
using namespace yams::search;
|
|
using namespace yams::vector;
|
|
using namespace yams::api;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
// ============================================================================
|
|
// Test Helpers
|
|
// ============================================================================
|
|
|
|
namespace {
|
|
|
|
/**
|
|
* Helper to run coroutines synchronously in tests
|
|
*/
|
|
template <typename T> yams::Result<T> runAwait(boost::asio::awaitable<yams::Result<T>> aw) {
|
|
boost::asio::io_context ioc;
|
|
|
|
auto wrapper =
|
|
[aw = std::move(aw)]() mutable -> boost::asio::awaitable<std::optional<yams::Result<T>>> {
|
|
try {
|
|
auto v = co_await std::move(aw);
|
|
co_return std::optional<yams::Result<T>>(std::move(v));
|
|
} catch (...) {
|
|
co_return std::optional<yams::Result<T>>{};
|
|
}
|
|
};
|
|
|
|
auto fut = boost::asio::co_spawn(ioc, wrapper(), boost::asio::use_future);
|
|
ioc.run();
|
|
auto opt = fut.get();
|
|
if (opt) {
|
|
return std::move(*opt);
|
|
}
|
|
return yams::Result<T>(yams::Error{yams::ErrorCode::InternalError, "Awaitable failed"});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test Fixtures
|
|
// ============================================================================
|
|
|
|
/**
|
|
* @brief Comprehensive test fixture for SearchService (Catch2 version)
|
|
*
|
|
* Sets up isolated test environment with:
|
|
* - Temporary database and storage
|
|
* - Sample documents (code, docs, binary)
|
|
* - Search engines (keyword, semantic, hybrid)
|
|
*/
|
|
class SearchServiceFixture {
|
|
public:
|
|
SearchServiceFixture() {
|
|
setupTestEnvironment();
|
|
setupDatabase();
|
|
setupStorage();
|
|
setupServices();
|
|
populateTestCorpus();
|
|
}
|
|
|
|
~SearchServiceFixture() {
|
|
cleanupServices();
|
|
cleanupDatabase();
|
|
cleanupTestEnvironment();
|
|
}
|
|
|
|
// Helper: Execute search request
|
|
::yams::app::services::SearchResponse executeSearch(::yams::app::services::SearchRequest req) {
|
|
// Disable session filtering for tests since we don't set up sessions
|
|
req.globalSearch = true;
|
|
auto result = runAwait(searchService_->search(req));
|
|
REQUIRE(result);
|
|
return result.value();
|
|
}
|
|
|
|
// Helper: Create code document
|
|
std::string createCodeDocument(const std::string& filename, const std::string& content,
|
|
const std::vector<std::string>& tags = {}) {
|
|
REQUIRE(fixtureManager_);
|
|
REQUIRE(docService_);
|
|
|
|
auto fixture = fixtureManager_->createTextFixture(filename, content, tags);
|
|
|
|
StoreDocumentRequest storeReq;
|
|
storeReq.path = fixture.path.string();
|
|
storeReq.tags = tags; // Store tags in metadata
|
|
auto storeResult = docService_->store(storeReq);
|
|
REQUIRE(storeResult);
|
|
|
|
// Index the content into FTS5 for keyword search
|
|
indexDocumentForSearch(storeResult.value().hash, filename, content);
|
|
|
|
return storeResult.value().hash;
|
|
}
|
|
|
|
// Helper: Create document
|
|
std::string createDocument(const std::string& filename, const std::string& content,
|
|
const std::vector<std::string>& tags = {}) {
|
|
REQUIRE(fixtureManager_);
|
|
REQUIRE(docService_);
|
|
|
|
auto fixture = fixtureManager_->createTextFixture(filename, content, tags);
|
|
|
|
StoreDocumentRequest storeReq;
|
|
storeReq.path = fixture.path.string();
|
|
storeReq.tags = tags; // Store tags in metadata
|
|
auto storeResult = docService_->store(storeReq);
|
|
REQUIRE(storeResult);
|
|
|
|
// Index the content into FTS5 for keyword search
|
|
indexDocumentForSearch(storeResult.value().hash, filename, content);
|
|
|
|
return storeResult.value().hash;
|
|
}
|
|
|
|
// Public member access for tests
|
|
std::shared_ptr<ISearchService> searchService() const { return searchService_; }
|
|
std::shared_ptr<IDocumentService> docService() const { return docService_; }
|
|
std::shared_ptr<yams::search::SearchEngine> searchEngine() const {
|
|
return appContext_.searchEngine;
|
|
}
|
|
|
|
// Test document hashes
|
|
std::string hashDoc1_; // pipeline.cpp
|
|
std::string hashDoc2_; // search_engine.cpp
|
|
std::string hashDoc3_; // README.md
|
|
std::string hashDoc4_; // test_search.cpp
|
|
std::string hashDoc5_; // unicode.md
|
|
std::string hashDoc6_; // logo.png
|
|
std::string hashDocStem1_; // searching_guide.md
|
|
std::string hashDocStem2_; // search_tutorial.md
|
|
std::string hashDocStem3_; // indexed_data.md
|
|
std::string hashDocCamel1_; // api_handler.cpp
|
|
std::string hashDocCamel2_; // file_system_utils.cpp
|
|
std::string hashDocCamel3_; // snake_case_example.py
|
|
std::string hashDocHyphen_; // config-parser.md
|
|
|
|
private:
|
|
void setupTestEnvironment() {
|
|
auto pid = std::to_string(::getpid());
|
|
auto timestamp =
|
|
std::to_string(std::chrono::system_clock::now().time_since_epoch().count());
|
|
testDir_ =
|
|
fs::temp_directory_path() / ("search_comprehensive_test_" + pid + "_" + timestamp);
|
|
fs::create_directories(testDir_);
|
|
|
|
fixtureManager_ = std::make_shared<yams::test::FixtureManager>(testDir_);
|
|
}
|
|
|
|
void setupDatabase() {
|
|
dbPath_ = fs::absolute(testDir_ / "test.db");
|
|
database_ = std::make_unique<Database>();
|
|
auto openResult = database_->open(dbPath_.string(), ConnectionMode::Create);
|
|
REQUIRE(openResult);
|
|
|
|
ConnectionPoolConfig poolConfig;
|
|
poolConfig.maxConnections = 4;
|
|
pool_ = std::make_unique<ConnectionPool>(dbPath_.string(), poolConfig);
|
|
metadataRepo_ = std::make_shared<MetadataRepository>(*pool_);
|
|
|
|
MigrationManager mm(*database_);
|
|
auto initResult = mm.initialize();
|
|
REQUIRE(initResult);
|
|
|
|
mm.registerMigrations(YamsMetadataMigrations::getAllMigrations());
|
|
auto migrateResult = mm.migrate();
|
|
REQUIRE(migrateResult);
|
|
}
|
|
|
|
void setupStorage() {
|
|
ContentStoreBuilder builder;
|
|
auto storePath = testDir_ / "storage";
|
|
auto storeResult = builder.withStoragePath(storePath)
|
|
.withChunkSize(65536)
|
|
.withCompression(true)
|
|
.withDeduplication(true)
|
|
.build();
|
|
REQUIRE(storeResult);
|
|
|
|
auto& uniqueStore = storeResult.value();
|
|
contentStore_ = std::shared_ptr<IContentStore>(
|
|
const_cast<std::unique_ptr<IContentStore>&>(uniqueStore).release());
|
|
}
|
|
|
|
void setupServices() {
|
|
appContext_.store = contentStore_;
|
|
appContext_.metadataRepo = metadataRepo_;
|
|
appContext_.workerExecutor = boost::asio::system_executor();
|
|
|
|
// Initialize hybrid search engine for integration testing
|
|
// Note: This requires vector DB and embeddings to be available
|
|
// Tests will skip when YAMS_DISABLE_VECTORS is set or embeddings unavailable
|
|
// On Windows, ONNX initialization hangs - always skip vectors
|
|
#ifdef _WIN32
|
|
const bool skipVectors = true;
|
|
#else
|
|
const bool skipVectors = (std::getenv("YAMS_DISABLE_VECTORS") != nullptr);
|
|
#endif
|
|
|
|
if (!skipVectors) {
|
|
try {
|
|
yams::search::SearchEngineBuilder builder;
|
|
builder.withMetadataRepo(metadataRepo_);
|
|
|
|
// SearchEngine now uses VectorDatabase directly (VectorIndexManager removed)
|
|
// Vector search configured via VectorDatabase when available
|
|
|
|
// Try to create embedding generator (may fail if ONNX not available)
|
|
yams::vector::EmbeddingConfig embCfg;
|
|
embCfg.model_name = "all-MiniLM-L6-v2";
|
|
embCfg.embedding_dim = 384;
|
|
auto embGen = std::make_shared<yams::vector::EmbeddingGenerator>(embCfg);
|
|
if (embGen->initialize()) {
|
|
builder.withEmbeddingGenerator(embGen);
|
|
}
|
|
|
|
auto opts = yams::search::SearchEngineBuilder::BuildOptions::makeDefault();
|
|
auto engineResult = builder.buildEmbedded(opts);
|
|
if (engineResult) {
|
|
appContext_.searchEngine = engineResult.value();
|
|
}
|
|
} catch (...) {
|
|
// Search engine initialization failed - tests will expect InvalidState errors
|
|
}
|
|
}
|
|
|
|
searchService_ = makeSearchService(appContext_);
|
|
REQUIRE(searchService_);
|
|
|
|
docService_ = makeDocumentService(appContext_);
|
|
REQUIRE(docService_);
|
|
}
|
|
|
|
void populateTestCorpus();
|
|
|
|
// Helper: Index document content into FTS5 for keyword search
|
|
void indexDocumentForSearch(const std::string& hash, const std::string& title,
|
|
const std::string& content) {
|
|
auto docResult = metadataRepo_->getDocumentByHash(hash);
|
|
if (!docResult || !docResult.value().has_value()) {
|
|
// Document not found in metadata - this can happen if store didn't add to metadata
|
|
return;
|
|
}
|
|
auto docInfo = docResult.value().value();
|
|
auto indexResult = metadataRepo_->indexDocumentContent(
|
|
docInfo.id, title, content, docInfo.mimeType.empty() ? "text/plain" : docInfo.mimeType);
|
|
if (!indexResult) {
|
|
spdlog::warn("Failed to index document content for FTS5: {}",
|
|
indexResult.error().message);
|
|
}
|
|
}
|
|
|
|
void cleanupServices() {
|
|
searchService_.reset();
|
|
docService_.reset();
|
|
}
|
|
|
|
void cleanupDatabase() {
|
|
appContext_.metadataRepo.reset();
|
|
appContext_.store.reset();
|
|
appContext_.searchEngine.reset();
|
|
contentStore_.reset();
|
|
metadataRepo_.reset();
|
|
if (pool_) {
|
|
pool_->shutdown();
|
|
pool_.reset();
|
|
}
|
|
if (database_) {
|
|
database_->close();
|
|
}
|
|
database_.reset();
|
|
}
|
|
|
|
void cleanupTestEnvironment() {
|
|
fixtureManager_.reset();
|
|
if (fs::exists(testDir_)) {
|
|
fs::remove_all(testDir_);
|
|
}
|
|
}
|
|
|
|
fs::path testDir_;
|
|
fs::path dbPath_;
|
|
std::unique_ptr<Database> database_;
|
|
std::unique_ptr<ConnectionPool> pool_;
|
|
std::shared_ptr<MetadataRepository> metadataRepo_;
|
|
std::shared_ptr<IContentStore> contentStore_;
|
|
AppContext appContext_;
|
|
std::shared_ptr<ISearchService> searchService_;
|
|
std::shared_ptr<IDocumentService> docService_;
|
|
std::shared_ptr<yams::test::FixtureManager> fixtureManager_;
|
|
};
|
|
|
|
void SearchServiceFixture::populateTestCorpus() {
|
|
// 1. Code: C++ indexing pipeline
|
|
hashDoc1_ = createCodeDocument("pipeline.cpp", R"(
|
|
#include "indexing_pipeline.hpp"
|
|
|
|
class IndexingPipeline {
|
|
void processDocument(const Document& doc) {
|
|
// Index document content
|
|
index_.add(doc.id, doc.content);
|
|
}
|
|
};
|
|
)",
|
|
{"code", "cpp"});
|
|
|
|
// 2. Code: C++ search engine
|
|
hashDoc2_ = createCodeDocument("search_engine.cpp", R"(
|
|
#include "search_engine.hpp"
|
|
|
|
class SearchEngine {
|
|
std::vector<Result> search(const Query& q) {
|
|
return index_.query(q.text);
|
|
}
|
|
};
|
|
)",
|
|
{"code", "cpp"});
|
|
|
|
// 3. Doc: Markdown README
|
|
hashDoc3_ = createDocument("README.md", R"(
|
|
# YAMS - Yet Another Metadata Store
|
|
|
|
YAMS is a content addressable storage system with search capabilities.
|
|
|
|
## Features
|
|
- Hybrid search (keyword + semantic)
|
|
- Document indexing and retrieval
|
|
- Metadata tracking
|
|
)",
|
|
{"docs", "markdown"});
|
|
|
|
// 4. Code: C++ test file
|
|
hashDoc4_ = createCodeDocument("test_search.cpp", R"(
|
|
#include <gtest/gtest.h>
|
|
#include "search_engine.hpp"
|
|
|
|
TEST(SearchEngineTest, BasicQuery) {
|
|
SearchEngine engine;
|
|
auto results = engine.search("test query");
|
|
EXPECT_GT(results.size(), 0);
|
|
}
|
|
)",
|
|
{"code", "cpp", "test"});
|
|
|
|
// 5. Doc: Unicode content
|
|
hashDoc5_ = createDocument("unicode.md", R"(
|
|
# Unicode Test Document
|
|
|
|
Testing multilingual support:
|
|
- English: Hello World
|
|
- Spanish: Hola Mundo
|
|
- French: Bonjour le Monde
|
|
- German: Hallo Welt
|
|
- Japanese: こんにちは世界
|
|
- Chinese: 你好世界
|
|
)",
|
|
{"docs", "unicode"});
|
|
|
|
// 6. Binary: PNG header
|
|
hashDoc6_ = createDocument("logo.png", "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x10",
|
|
{"binary", "image"});
|
|
|
|
// 7-9. Stemming tests: "search" variations
|
|
hashDocStem1_ = createDocument("searching_guide.md", R"(
|
|
# Guide to Searching
|
|
|
|
This guide covers searching techniques.
|
|
Effective searching requires practice.
|
|
)",
|
|
{"docs", "stemming"});
|
|
|
|
hashDocStem2_ = createDocument("search_tutorial.md", R"(
|
|
# Search Tutorial
|
|
|
|
Learn how to search efficiently.
|
|
Use search operators for better results.
|
|
)",
|
|
{"docs", "stemming"});
|
|
|
|
hashDocStem3_ = createDocument("indexed_data.md", R"(
|
|
# Indexed Data
|
|
|
|
This document is indexed for fast retrieval.
|
|
Indexing improves search performance.
|
|
)",
|
|
{"docs", "stemming"});
|
|
|
|
// 10-12. CamelCase tests
|
|
hashDocCamel1_ = createCodeDocument("api_handler.cpp", R"(
|
|
class ApiRequestHandler {
|
|
void handleGetRequest() { }
|
|
void handlePostRequest() { }
|
|
};
|
|
)",
|
|
{"code", "camelcase"});
|
|
|
|
hashDocCamel2_ = createCodeDocument("file_system_utils.cpp", R"(
|
|
class FileSystemUtils {
|
|
void createDirectory() { }
|
|
void deleteDirectory() { }
|
|
};
|
|
)",
|
|
{"code", "camelcase"});
|
|
|
|
hashDocCamel3_ = createCodeDocument("snake_case_example.py", R"(
|
|
def process_user_input():
|
|
pass
|
|
|
|
def validate_input():
|
|
pass
|
|
)",
|
|
{"code", "python", "snakecase"});
|
|
|
|
// Hyphenated token test document
|
|
hashDocHyphen_ = createDocument(
|
|
"config-parser.md",
|
|
"The config-parser module handles command-line arguments and configuration files.",
|
|
{"docs", "config"});
|
|
}
|
|
|
|
// TEMPORARY: Disable broken inheritance-based mocks
|
|
// These tests need to be rewritten as integration tests
|
|
#if 0
|
|
|
|
/**
|
|
* @brief BROKEN: Cannot mock KeywordSearchEngine this way
|
|
* KeywordSearchEngine may not have virtual methods or may use PIMPL
|
|
*/
|
|
class ControllableKeywordEngine : public KeywordSearchEngine {
|
|
public:
|
|
// Control knobs
|
|
std::chrono::milliseconds search_delay{0};
|
|
bool should_fail{false};
|
|
std::vector<KeywordSearchResult> canned_results;
|
|
|
|
// Recorded calls
|
|
size_t search_call_count{0};
|
|
std::string last_query;
|
|
|
|
std::vector<std::string> analyzeQuery(const std::string& query) const override {
|
|
std::vector<std::string> tokens;
|
|
std::istringstream iss(query);
|
|
std::string token;
|
|
while (iss >> token) {
|
|
std::transform(token.begin(), token.end(), token.begin(), ::tolower);
|
|
tokens.push_back(token);
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
std::vector<std::string> extractKeywords(const std::string& text) const override {
|
|
return analyzeQuery(text);
|
|
}
|
|
|
|
Result<std::vector<KeywordSearchResult>> search(const std::string& query, size_t k,
|
|
const SearchFilter* filter) override {
|
|
search_call_count++;
|
|
last_query = query;
|
|
|
|
if (search_delay.count() > 0) {
|
|
std::this_thread::sleep_for(search_delay);
|
|
}
|
|
|
|
if (should_fail) {
|
|
return Result<std::vector<KeywordSearchResult>>(
|
|
Error{ErrorCode::Unknown, "Mock failure"});
|
|
}
|
|
|
|
std::vector<KeywordSearchResult> results;
|
|
for (size_t i = 0; i < std::min(k, canned_results.size()); ++i) {
|
|
results.push_back(canned_results[i]);
|
|
}
|
|
return Result<std::vector<KeywordSearchResult>>(results);
|
|
}
|
|
|
|
Result<std::vector<std::vector<KeywordSearchResult>>>
|
|
batchSearch(const std::vector<std::string>&, size_t, const SearchFilter*) override {
|
|
return Result<std::vector<std::vector<KeywordSearchResult>>>(
|
|
std::vector<std::vector<KeywordSearchResult>>{});
|
|
}
|
|
|
|
Result<void> addDocument(const std::string&, const std::string&,
|
|
const std::map<std::string, std::string>&) override {
|
|
return Result<void>();
|
|
}
|
|
|
|
Result<void> removeDocument(const std::string&) override { return Result<void>(); }
|
|
|
|
Result<void> updateDocument(const std::string&, const std::string&,
|
|
const std::map<std::string, std::string>&) override {
|
|
return Result<void>();
|
|
}
|
|
|
|
Result<void> addDocuments(const std::vector<std::string>&, const std::vector<std::string>&,
|
|
const std::vector<std::map<std::string, std::string>>&) override {
|
|
return Result<void>();
|
|
}
|
|
|
|
Result<void> buildIndex() override { return Result<void>(); }
|
|
Result<void> optimizeIndex() override { return Result<void>(); }
|
|
Result<void> clearIndex() override { return Result<void>(); }
|
|
Result<void> saveIndex(const std::string&) override { return Result<void>(); }
|
|
Result<void> loadIndex(const std::string&) override { return Result<void>(); }
|
|
|
|
size_t getDocumentCount() const override { return canned_results.size(); }
|
|
size_t getTermCount() const override { return 0; }
|
|
size_t getIndexSize() const override { return 0; }
|
|
};
|
|
|
|
// VectorIndexManager removed - SearchEngine now uses VectorDatabase directly
|
|
|
|
/**
|
|
* @brief Mock embedding generator for testing
|
|
*/
|
|
class MockEmbeddingGenerator : public EmbeddingGenerator {
|
|
public:
|
|
std::chrono::milliseconds generation_delay{0};
|
|
bool should_timeout{false};
|
|
|
|
bool initialize() override {
|
|
initialized_ = true;
|
|
return true;
|
|
}
|
|
|
|
bool isInitialized() const override { return initialized_; }
|
|
|
|
std::vector<float> generateEmbedding(const std::string& text) override {
|
|
if (should_timeout) {
|
|
std::this_thread::sleep_for(std::chrono::seconds(1)); // Exceed timeout
|
|
}
|
|
if (generation_delay.count() > 0) {
|
|
std::this_thread::sleep_for(generation_delay);
|
|
}
|
|
// Return simple embedding based on text length
|
|
return std::vector<float>(384, static_cast<float>(text.length()) / 100.0f);
|
|
}
|
|
|
|
std::future<std::vector<float>> generateEmbeddingAsync(const std::string& text) override {
|
|
return std::async(std::launch::async, [this, text]() { return generateEmbedding(text); });
|
|
}
|
|
|
|
EmbeddingConfig getConfig() const override {
|
|
EmbeddingConfig config;
|
|
config.embedding_dim = 384;
|
|
return config;
|
|
}
|
|
|
|
private:
|
|
bool initialized_{false};
|
|
};
|
|
|
|
using yams::test::ScopedEnvVar;
|
|
|
|
#endif // Disabled broken mocks
|
|
|
|
} // namespace
|
|
|
|
// ============================================================================
|
|
// Keyword Search Tests (Migrated from GTest)
|
|
// ============================================================================
|
|
|
|
TEST_CASE("KeywordSearch - Exact match", "[search][keyword]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "IndexingPipeline";
|
|
req.type = "keyword";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
REQUIRE(resp.results.size() > 0);
|
|
REQUIRE(resp.results[0].path.find("pipeline.cpp") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("KeywordSearch - Case insensitive", "[search][keyword]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "indexingpipeline"; // lowercase
|
|
req.type = "keyword";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should match "IndexingPipeline" (case-insensitive)
|
|
REQUIRE(resp.results.size() > 0);
|
|
REQUIRE(resp.results[0].path.find("pipeline.cpp") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("KeywordSearch - Stemming", "[search][keyword][stemming]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Test FTS5 Porter stemmer (Migration v17)
|
|
// Query "search" should match "search", "searching", "searches", "searched"
|
|
app::services::SearchRequest req;
|
|
req.query = "search";
|
|
req.type = "keyword";
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should match multiple documents with different forms of "search"
|
|
REQUIRE(resp.results.size() >= 3);
|
|
|
|
// Verify we match documents with different stem forms
|
|
bool foundSearching = false; // searching_guide.md has "searching"
|
|
bool foundSearch = false; // search_tutorial.md has "search"
|
|
bool foundSearchEngine = false; // search_engine.cpp has "search" in class name
|
|
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("searching_guide.md") != std::string::npos) {
|
|
foundSearching = true;
|
|
}
|
|
if (result.path.find("search_tutorial.md") != std::string::npos) {
|
|
foundSearch = true;
|
|
}
|
|
if (result.path.find("search_engine.cpp") != std::string::npos) {
|
|
foundSearchEngine = true;
|
|
}
|
|
}
|
|
|
|
REQUIRE(foundSearching);
|
|
REQUIRE(foundSearch);
|
|
REQUIRE(foundSearchEngine);
|
|
}
|
|
|
|
TEST_CASE("HybridSearch - Concept boost elevates matches", "[search][hybrid][concept]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
auto engine = fixture.searchEngine();
|
|
if (!engine) {
|
|
SUCCEED("SearchEngine not available; skipping concept boost test.");
|
|
return;
|
|
}
|
|
|
|
auto cfg = engine->getConfig();
|
|
cfg.conceptBoostWeight = 0.8f;
|
|
cfg.conceptMaxBoost = 0.8f;
|
|
cfg.conceptMinConfidence = 0.0f;
|
|
cfg.conceptMaxScanResults = 1;
|
|
engine->setConfig(cfg);
|
|
engine->setConceptExtractor(
|
|
[](const std::string& /*query*/, const std::vector<std::string>& /*types*/) {
|
|
QueryConceptResult result;
|
|
result.concepts.push_back(QueryConcept{"pipeline", "concept", 0.9f, 0, 8});
|
|
return Result<QueryConceptResult>(result);
|
|
});
|
|
|
|
fixture.createDocument("pipeline_notes.md", "pipeline boosttoken summary");
|
|
fixture.createDocument("search_notes.md", "unrelated content only");
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "boosttoken";
|
|
req.type = "hybrid";
|
|
req.limit = 5;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
REQUIRE(resp.results.size() > 0);
|
|
CHECK(resp.results[0].path.find("pipeline_notes.md") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("HybridSearch - Concept boost respects scan cap", "[search][hybrid][concept]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
auto engine = fixture.searchEngine();
|
|
if (!engine) {
|
|
SUCCEED("SearchEngine not available; skipping concept cap test.");
|
|
return;
|
|
}
|
|
|
|
auto cfg = engine->getConfig();
|
|
cfg.conceptBoostWeight = 0.8f;
|
|
cfg.conceptMaxBoost = 0.8f;
|
|
cfg.conceptMinConfidence = 0.0f;
|
|
cfg.conceptMaxScanResults = 1;
|
|
engine->setConfig(cfg);
|
|
engine->setConceptExtractor(
|
|
[](const std::string& /*query*/, const std::vector<std::string>& /*types*/) {
|
|
QueryConceptResult result;
|
|
result.concepts.push_back(QueryConcept{"pipeline", "concept", 0.9f, 0, 8});
|
|
return Result<QueryConceptResult>(result);
|
|
});
|
|
|
|
fixture.createDocument("pipeline_notes.md", "pipeline boosttoken summary");
|
|
fixture.createDocument("search_notes.md", "boosttoken boosttoken boosttoken");
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "boosttoken";
|
|
req.type = "hybrid";
|
|
req.limit = 5;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
REQUIRE(resp.results.size() > 0);
|
|
CHECK(resp.results[0].path.find("search_notes.md") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("HybridSearch - Delayed concept extraction remains deterministic",
|
|
"[search][hybrid][concept]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
auto engine = fixture.searchEngine();
|
|
if (!engine) {
|
|
SUCCEED("SearchEngine not available; skipping delayed concept extraction test.");
|
|
return;
|
|
}
|
|
|
|
auto cfg = engine->getConfig();
|
|
cfg.conceptBoostWeight = 0.8f;
|
|
cfg.conceptMaxBoost = 0.8f;
|
|
cfg.conceptMinConfidence = 0.0f;
|
|
cfg.conceptMaxScanResults = 2;
|
|
engine->setConfig(cfg);
|
|
engine->setConceptExtractor(
|
|
[](const std::string& /*query*/, const std::vector<std::string>& /*types*/) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
QueryConceptResult result;
|
|
result.concepts.push_back(QueryConcept{"pipeline", "concept", 0.9f, 0, 8});
|
|
return Result<QueryConceptResult>(result);
|
|
});
|
|
|
|
fixture.createDocument("pipeline_notes.md", "pipeline boosttoken summary");
|
|
fixture.createDocument("search_notes.md", "boosttoken boosttoken boosttoken");
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "boosttoken";
|
|
req.type = "hybrid";
|
|
req.limit = 5;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
REQUIRE(resp.results.size() > 0);
|
|
CHECK(resp.results[0].path.find("pipeline_notes.md") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("KeywordSearch - Stemming reverse", "[search][keyword][stemming]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Test stemming in reverse: query "searching" should match "search"
|
|
app::services::SearchRequest req;
|
|
req.query = "searching";
|
|
req.type = "keyword";
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
REQUIRE(resp.results.size() >= 2);
|
|
|
|
bool foundSearchTutorial = false; // search_tutorial.md has base form "search"
|
|
bool foundSearchingGuide = false; // searching_guide.md has "searching"
|
|
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("search_tutorial.md") != std::string::npos) {
|
|
foundSearchTutorial = true;
|
|
}
|
|
if (result.path.find("searching_guide.md") != std::string::npos) {
|
|
foundSearchingGuide = true;
|
|
}
|
|
}
|
|
|
|
REQUIRE(foundSearchTutorial);
|
|
REQUIRE(foundSearchingGuide);
|
|
}
|
|
|
|
TEST_CASE("KeywordSearch - Stemming indexed", "[search][keyword][stemming]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Test stemming with "index" -> "indexed", "indexing"
|
|
app::services::SearchRequest req;
|
|
req.query = "index";
|
|
req.type = "keyword";
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
REQUIRE(resp.results.size() >= 1);
|
|
|
|
bool foundIndexedData = false; // indexed_data.md has "indexed" and "indexing"
|
|
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("indexed_data.md") != std::string::npos) {
|
|
foundIndexedData = true;
|
|
}
|
|
}
|
|
|
|
REQUIRE(foundIndexedData);
|
|
|
|
// Verify stemming works: query "indexing" should also match "index" and "indexed"
|
|
app::services::SearchRequest req2;
|
|
req2.query = "indexing";
|
|
req2.type = "keyword";
|
|
req2.limit = 20;
|
|
|
|
auto resp2 = fixture.executeSearch(req2);
|
|
|
|
bool foundIndexedData2 = false;
|
|
for (const auto& result : resp2.results) {
|
|
if (result.path.find("indexed_data.md") != std::string::npos) {
|
|
foundIndexedData2 = true;
|
|
}
|
|
}
|
|
|
|
REQUIRE(foundIndexedData2);
|
|
}
|
|
|
|
// ============================================================================
|
|
// CamelCase and Tokenization Tests (Task 071-03)
|
|
// ============================================================================
|
|
|
|
TEST_CASE("KeywordSearch - CamelCase whole token", "[search][keyword][camelcase]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// FTS5 treats CamelCase identifiers as single tokens
|
|
// Query "ApiRequestHandler" should match the exact identifier
|
|
app::services::SearchRequest req;
|
|
req.query = "ApiRequestHandler";
|
|
req.type = "keyword";
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
bool foundApiHandler = false;
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("api_handler.cpp") != std::string::npos) {
|
|
foundApiHandler = true;
|
|
}
|
|
}
|
|
|
|
REQUIRE(foundApiHandler);
|
|
}
|
|
|
|
TEST_CASE("KeywordSearch - CamelCase partial no match", "[search][keyword][camelcase]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// FTS5 does NOT split CamelCase - "Request" won't match "ApiRequestHandler"
|
|
// This validates FTS5 tokenization behavior for programming identifiers
|
|
app::services::SearchRequest req;
|
|
req.query = "Request";
|
|
req.type = "keyword";
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should match documents with standalone "Request" words but not CamelCase parts
|
|
// The query should work, just documenting tokenization behavior
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
TEST_CASE("KeywordSearch - CamelCase lowercase match", "[search][keyword][camelcase]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Searching for lowercase "handler" should match if FTS5 case-folding is enabled
|
|
app::services::SearchRequest req;
|
|
req.query = "handler";
|
|
req.type = "keyword";
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// FTS5 is case-insensitive, but CamelCase is one token
|
|
// "handler" won't match "ApiRequestHandler" (it's a substring, not a token)
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
TEST_CASE("KeywordSearch - snake_case as single token", "[search][keyword][snakecase]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// FTS5 is configured with tokenchars='_-' so snake_case identifiers are single tokens
|
|
// Query "process_user_input" should match the exact identifier
|
|
app::services::SearchRequest req;
|
|
req.query = "process_user_input";
|
|
req.type = "keyword";
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
bool foundSnakeCaseFile = false;
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("snake_case_example.py") != std::string::npos) {
|
|
foundSnakeCaseFile = true;
|
|
}
|
|
}
|
|
|
|
REQUIRE(foundSnakeCaseFile);
|
|
}
|
|
|
|
TEST_CASE("KeywordSearch - Hyphenated as single token", "[search][keyword][tokenization]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// FTS5 is configured with tokenchars='_-' so hyphenated terms are single tokens
|
|
// Document created in populateTestCorpus(): config-parser.md
|
|
// Query for the exact hyphenated term
|
|
app::services::SearchRequest req;
|
|
req.query = "config-parser";
|
|
req.type = "keyword";
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
bool foundHyphenDoc = false;
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("config-parser.md") != std::string::npos) {
|
|
foundHyphenDoc = true;
|
|
}
|
|
}
|
|
|
|
REQUIRE(foundHyphenDoc);
|
|
}
|
|
|
|
TEST_CASE("KeywordSearch - Mixed case identifier", "[search][keyword][camelcase]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Verify mixed case identifiers are searchable
|
|
app::services::SearchRequest req;
|
|
req.query = "FileSystemUtils";
|
|
req.type = "keyword";
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
bool foundFileSystemUtils = false;
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("file_system_utils.cpp") != std::string::npos) {
|
|
foundFileSystemUtils = true;
|
|
}
|
|
}
|
|
|
|
REQUIRE(foundFileSystemUtils);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Semantic Search Tests (Fallback/Degraded Mode)
|
|
// ============================================================================
|
|
|
|
TEST_CASE("SemanticSearch - Graceful fallback to keyword", "[search][semantic][fallback]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// When no hybrid engine is provided, semantic search gracefully falls back to keyword search
|
|
app::services::SearchRequest req;
|
|
req.query = "indexing pipeline";
|
|
req.type = "semantic";
|
|
req.limit = 10;
|
|
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
|
|
// Should succeed via keyword fallback
|
|
REQUIRE(result);
|
|
// Results may be empty if no matches, but search should not error
|
|
REQUIRE(result.value().total >= 0);
|
|
}
|
|
|
|
TEST_CASE("SemanticSearch - Fallback with similarity threshold", "[search][semantic][fallback]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// When hybrid engine unavailable, falls back to keyword search (similarity ignored)
|
|
app::services::SearchRequest req;
|
|
req.query = "document indexing";
|
|
req.type = "semantic";
|
|
req.similarity = 0.9f; // Very high threshold - ignored in keyword fallback
|
|
req.limit = 10;
|
|
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
|
|
// Should succeed via keyword fallback
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().total >= 0);
|
|
}
|
|
|
|
TEST_CASE("SemanticSearch - Empty query", "[search][semantic][validation]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "";
|
|
req.type = "semantic";
|
|
req.limit = 10;
|
|
|
|
// Empty query should fail validation
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
REQUIRE_FALSE(result);
|
|
if (!result) {
|
|
REQUIRE(result.error().message.find("required") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SemanticSearch - Long query fallback", "[search][semantic][fallback]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Long queries fall back to keyword search when no hybrid engine
|
|
std::string longQuery;
|
|
for (int i = 0; i < 200; ++i) {
|
|
longQuery += "This is a very long query with many repeated words. ";
|
|
}
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = longQuery;
|
|
req.type = "semantic";
|
|
req.limit = 10;
|
|
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
|
|
// Should succeed via keyword fallback (handles gracefully)
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().total >= 0);
|
|
}
|
|
|
|
TEST_CASE("SemanticSearch - Low similarity fallback", "[search][semantic][fallback]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Low similarity threshold - falls back to keyword search
|
|
app::services::SearchRequest req;
|
|
req.query = "code";
|
|
req.type = "semantic";
|
|
req.similarity = 0.1f; // Very low threshold - ignored in keyword fallback
|
|
req.limit = 20;
|
|
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
|
|
// Should succeed via keyword fallback
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().total >= 0);
|
|
}
|
|
|
|
TEST_CASE("SemanticSearch - Conceptual query fallback", "[search][semantic][fallback]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Conceptual queries fall back to keyword search
|
|
app::services::SearchRequest req;
|
|
req.query = "processing workflow";
|
|
req.type = "semantic";
|
|
req.limit = 10;
|
|
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
|
|
// Should succeed via keyword fallback
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().total >= 0);
|
|
}
|
|
|
|
TEST_CASE("SemanticSearch - Multilingual fallback", "[search][semantic][fallback]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Multilingual queries fall back to keyword search
|
|
app::services::SearchRequest req;
|
|
req.query = "système de métadonnées"; // French: "metadata system"
|
|
req.type = "semantic";
|
|
req.limit = 10;
|
|
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
|
|
// Should succeed via keyword fallback
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().total >= 0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Filtering Tests
|
|
// ============================================================================
|
|
|
|
TEST_CASE("Filter - By path", "[search][filter][path]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "class";
|
|
req.type = "keyword";
|
|
req.pathPatterns = {"src/**/*.cpp"};
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should only match files in src/ directory
|
|
for (const auto& result : resp.results) {
|
|
REQUIRE(result.path.find("src/") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Filter - Engine path predicate runs before final top-k",
|
|
"[search][filter][path][engine]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
auto engine = fixture.searchEngine();
|
|
if (!engine) {
|
|
SUCCEED("SearchEngine not available; skipping engine path predicate test.");
|
|
return;
|
|
}
|
|
|
|
fixture.createDocument("build/generated_scope.txt",
|
|
"scopewindow scopewindow scopewindow scopewindow");
|
|
fixture.createDocument("src/authored_scope.txt", "scopewindow");
|
|
|
|
SearchParams params;
|
|
params.limit = 1;
|
|
params.pathPredicate = [](std::string_view path) {
|
|
return path.find("/src/") != std::string_view::npos;
|
|
};
|
|
|
|
auto result = engine->search("scopewindow", params);
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().size() == 1);
|
|
CHECK(result.value().front().document.filePath.find("/src/authored_scope.txt") !=
|
|
std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("Filter - By tags", "[search][filter][tags]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "class";
|
|
req.type = "keyword";
|
|
req.tags = {"code"};
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// All results should have "code" tag
|
|
REQUIRE(resp.results.size() > 0);
|
|
}
|
|
|
|
TEST_CASE("Filter - By file type", "[search][filter][type]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "test";
|
|
req.fileType = "text";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should not return binary files
|
|
for (const auto& result : resp.results) {
|
|
REQUIRE(result.path.find("logo.png") == std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Filter - By MIME type", "[search][filter][mime]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "YAMS"; // Need non-empty query
|
|
req.mimeType = "text/markdown";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should only return markdown files
|
|
for (const auto& result : resp.results) {
|
|
REQUIRE(result.path.ends_with(".md"));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Filter - Multiple path patterns", "[search][filter][path]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Test multiple path patterns (OR logic)
|
|
app::services::SearchRequest req;
|
|
req.query = "class";
|
|
req.type = "keyword";
|
|
req.pathPatterns = {"**/*.cpp", "**/*.hpp"};
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should match files with either .cpp or .hpp extension
|
|
for (const auto& result : resp.results) {
|
|
bool isCppOrHpp = (result.path.find(".cpp") != std::string::npos) ||
|
|
(result.path.find(".hpp") != std::string::npos);
|
|
REQUIRE(isCppOrHpp);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Filter - Multiple tags", "[search][filter][tags]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Test multiple tags (should match documents with any of the tags)
|
|
app::services::SearchRequest req;
|
|
req.query = "test";
|
|
req.type = "keyword";
|
|
req.tags = {"code", "docs"};
|
|
req.matchAllTags = false; // Match any tag
|
|
req.limit = 20;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should return results (documents with either "code" or "docs" tag)
|
|
REQUIRE(resp.results.size() > 0);
|
|
}
|
|
|
|
TEST_CASE("Filter - Tag mismatch excludes results", "[search][filter][tags][hybrid]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "SearchEngine"; // present in tagged code corpus
|
|
req.tags = {"code"};
|
|
req.matchAllTags = true;
|
|
req.limit = 5;
|
|
|
|
auto tagged = fixture.executeSearch(req);
|
|
REQUIRE_FALSE(tagged.results.empty());
|
|
for (const auto& result : tagged.results) {
|
|
const bool in_expected_corpus =
|
|
result.path.find("search_engine.cpp") != std::string::npos ||
|
|
result.path.find("test_search.cpp") != std::string::npos ||
|
|
result.path.find("pipeline.cpp") != std::string::npos;
|
|
REQUIRE(in_expected_corpus);
|
|
}
|
|
|
|
req.tags = {"binary"}; // mismatch: should filter everything out
|
|
auto mismatched = fixture.executeSearch(req);
|
|
REQUIRE(mismatched.results.empty());
|
|
REQUIRE(mismatched.total == 0);
|
|
}
|
|
|
|
TEST_CASE("Filter - Combined path and tags", "[search][filter][combined]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Test combining path pattern and tag filters
|
|
app::services::SearchRequest req;
|
|
req.query = "class";
|
|
req.type = "keyword";
|
|
req.pathPatterns = {"**/*.cpp"};
|
|
req.tags = {"code"};
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Results should satisfy both filters
|
|
for (const auto& result : resp.results) {
|
|
REQUIRE(result.path.find(".cpp") != std::string::npos);
|
|
}
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
TEST_CASE("Filter - Empty path pattern", "[search][filter][path]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Empty path pattern should not filter
|
|
app::services::SearchRequest req;
|
|
req.query = "pipeline";
|
|
req.type = "keyword";
|
|
req.pathPatterns = {}; // Empty
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should return results without path filtering
|
|
REQUIRE(resp.results.size() > 0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Edge Case Tests
|
|
// ============================================================================
|
|
|
|
TEST_CASE("EdgeCase - Empty query", "[search][edge][validation]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "";
|
|
req.limit = 10;
|
|
|
|
// Empty query should return error
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
REQUIRE_FALSE(result);
|
|
REQUIRE(result.error().code == ErrorCode::InvalidArgument);
|
|
REQUIRE(result.error().message.find("required") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("EdgeCase - Very long query", "[search][edge]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = std::string(10000, 'a'); // 10K character query
|
|
req.type = "keyword";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should handle gracefully
|
|
REQUIRE(resp.total >= 0);
|
|
}
|
|
|
|
TEST_CASE("EdgeCase - Unicode document search", "[search][edge][unicode]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Note: FTS5 with porter tokenizer doesn't properly tokenize CJK characters.
|
|
// Search for "multilingual" which is in the unicode.md document.
|
|
app::services::SearchRequest req;
|
|
req.query = "multilingual";
|
|
req.type = "keyword";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should find unicode.md which contains "Testing multilingual support"
|
|
bool found = false;
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("unicode.md") != std::string::npos) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
REQUIRE(found);
|
|
}
|
|
|
|
TEST_CASE("EdgeCase - Special characters", "[search][edge]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "class";
|
|
req.type = "keyword";
|
|
req.literalText = true; // Escape special chars
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should match files containing "class"
|
|
REQUIRE(resp.results.size() > 0);
|
|
}
|
|
|
|
TEST_CASE("EdgeCase - Limit zero", "[search][edge][limit]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "class";
|
|
req.limit = 0;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Zero limit means unlimited
|
|
REQUIRE(resp.total >= 0);
|
|
if (resp.total > 0) {
|
|
REQUIRE(resp.results.size() > 0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("EdgeCase - Large limit", "[search][edge][limit]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "test";
|
|
req.limit = 10000; // Very large limit
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should cap at available results
|
|
REQUIRE(resp.results.size() <= 10000);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Session Gating Tests
|
|
// ============================================================================
|
|
|
|
TEST_CASE("SessionGating - No session uses cold path", "[search][session][cold]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "pipeline";
|
|
req.type = "keyword";
|
|
req.useSession = false; // Explicit cold path
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Cold path should scan CAS directly (comprehensive)
|
|
REQUIRE(resp.results.size() > 0);
|
|
bool found = false;
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("pipeline.cpp") != std::string::npos) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
REQUIRE(found);
|
|
}
|
|
|
|
TEST_CASE("SessionGating - With session allows hot path", "[search][session][hot]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "pipeline";
|
|
req.type = "keyword";
|
|
req.useSession = true; // Allow hot path optimization
|
|
req.sessionName = "test-session";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Hot path uses extracted text from metadata (faster)
|
|
REQUIRE(resp.results.size() > 0);
|
|
bool found = false;
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("pipeline.cpp") != std::string::npos) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
REQUIRE(found);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Pagination Tests
|
|
// ============================================================================
|
|
|
|
TEST_CASE("Pagination - Limit enforcement", "[search][pagination]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Get first page with limit
|
|
app::services::SearchRequest req1;
|
|
req1.query = "class";
|
|
req1.limit = 2;
|
|
|
|
auto resp1 = fixture.executeSearch(req1);
|
|
|
|
// Should respect limit
|
|
REQUIRE(resp1.results.size() <= 2);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hybrid Search Tests
|
|
// ============================================================================
|
|
|
|
TEST_CASE("HybridSearch - RRF fusion fallback", "[search][hybrid][fallback]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "indexing pipeline";
|
|
req.type = "hybrid"; // Use RRF fusion
|
|
req.limit = 10;
|
|
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
|
|
// Without vector engine, hybrid search should gracefully fall back to keyword search
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().total >= 0);
|
|
}
|
|
|
|
TEST_CASE("HybridSearch - Engine unavailable fallback", "[search][hybrid][fallback]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "pipeline";
|
|
req.type = "hybrid";
|
|
req.limit = 10;
|
|
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
|
|
// Without hybrid engine, should fall back to keyword search successfully
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().total >= 0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// BATCH 8: Multiword Keyword + Concurrency Tests
|
|
// ============================================================================
|
|
|
|
TEST_CASE("KeywordSearch - Multiword query", "[search][keyword][multiword]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "content addressable storage";
|
|
req.type = "keyword";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
// Should match README.md which contains all these words
|
|
REQUIRE(resp.results.size() > 0);
|
|
bool found = false;
|
|
for (const auto& result : resp.results) {
|
|
if (result.path.find("README.md") != std::string::npos) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
REQUIRE(found);
|
|
}
|
|
|
|
TEST_CASE("Concurrency - Parallel searches", "[search][concurrency][parallel]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
const int numThreads = 10;
|
|
std::vector<app::services::SearchResponse> responses;
|
|
responses.reserve(numThreads);
|
|
|
|
// Execute searches sequentially but test the service handles them properly
|
|
// Note: True concurrency would require separate fixture instances per thread
|
|
// or a thread-safe database configuration
|
|
for (int i = 0; i < numThreads; ++i) {
|
|
app::services::SearchRequest req;
|
|
req.query = (i % 2 == 0) ? "pipeline" : "search";
|
|
req.type = "keyword";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
responses.push_back(resp);
|
|
REQUIRE(resp.results.size() > 0);
|
|
}
|
|
|
|
// Verify all searches completed successfully
|
|
REQUIRE(responses.size() == numThreads);
|
|
}
|
|
|
|
TEST_CASE("Concurrency - Search while indexing", "[search][concurrency][indexing]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Execute initial search
|
|
app::services::SearchRequest req;
|
|
req.query = "pipeline";
|
|
req.limit = 10;
|
|
auto resp1 = fixture.executeSearch(req);
|
|
REQUIRE(resp1.results.size() > 0);
|
|
auto initialCount = resp1.results.size();
|
|
|
|
// Add a new document (sequential to avoid database contention)
|
|
fixture.createCodeDocument("src/concurrent/new_file.cpp",
|
|
"class NewClass { void concurrent(); };", {"code", "concurrent"});
|
|
|
|
// Search again - should still work after indexing
|
|
auto resp2 = fixture.executeSearch(req);
|
|
REQUIRE(resp2.results.size() >= initialCount);
|
|
}
|
|
|
|
TEST_CASE("Concurrency - Different search queries", "[search][concurrency][queries]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
std::vector<app::services::SearchResponse> responses;
|
|
|
|
// Execute 3 different searches sequentially (load testing)
|
|
// Note: True concurrency would require thread-safe database configuration
|
|
{
|
|
app::services::SearchRequest req;
|
|
req.query = "pipeline";
|
|
req.type = "keyword";
|
|
req.limit = 10;
|
|
responses.push_back(fixture.executeSearch(req));
|
|
}
|
|
|
|
{
|
|
app::services::SearchRequest req;
|
|
req.query = "search";
|
|
req.type = "keyword";
|
|
req.limit = 10;
|
|
responses.push_back(fixture.executeSearch(req));
|
|
}
|
|
|
|
{
|
|
app::services::SearchRequest req;
|
|
req.query = "indexing";
|
|
req.type = "keyword";
|
|
req.limit = 10;
|
|
responses.push_back(fixture.executeSearch(req));
|
|
}
|
|
|
|
// Validate all searches succeeded
|
|
for (const auto& resp : responses) {
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
REQUIRE(responses.size() == 3);
|
|
}
|
|
|
|
TEST_CASE("Concurrency - Same query different filters", "[search][concurrency][filters]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
std::vector<app::services::SearchResponse> responses;
|
|
|
|
// Execute 4 searches with different filters sequentially (load testing)
|
|
{
|
|
app::services::SearchRequest req;
|
|
req.query = "class";
|
|
req.type = "keyword";
|
|
req.pathPatterns = {"**/*.cpp"};
|
|
req.limit = 10;
|
|
responses.push_back(fixture.executeSearch(req));
|
|
}
|
|
|
|
{
|
|
app::services::SearchRequest req;
|
|
req.query = "class";
|
|
req.type = "keyword";
|
|
req.tags = {"code"};
|
|
req.limit = 10;
|
|
responses.push_back(fixture.executeSearch(req));
|
|
}
|
|
|
|
{
|
|
app::services::SearchRequest req;
|
|
req.query = "class";
|
|
req.type = "keyword";
|
|
req.fileType = "text";
|
|
req.limit = 10;
|
|
responses.push_back(fixture.executeSearch(req));
|
|
}
|
|
|
|
{
|
|
app::services::SearchRequest req;
|
|
req.query = "class";
|
|
req.type = "keyword";
|
|
req.similarity = 0.9f;
|
|
req.limit = 10;
|
|
responses.push_back(fixture.executeSearch(req));
|
|
}
|
|
|
|
// Validate all searches succeeded
|
|
for (const auto& resp : responses) {
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
REQUIRE(responses.size() == 4);
|
|
}
|
|
|
|
TEST_CASE("Concurrency - High load stress test", "[search][concurrency][stress]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
const int numSearches = 20;
|
|
std::vector<app::services::SearchResponse> responses;
|
|
responses.reserve(numSearches);
|
|
|
|
// Execute many searches sequentially to test service stability under load
|
|
for (int i = 0; i < numSearches; ++i) {
|
|
app::services::SearchRequest req;
|
|
req.query = (i % 2 == 0) ? "pipeline" : "search";
|
|
req.type = "keyword"; // All keyword to avoid hybrid engine requirement
|
|
req.limit = 5;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
responses.push_back(resp);
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
// Validate all searches completed successfully
|
|
REQUIRE(responses.size() == numSearches);
|
|
}
|
|
|
|
// ============================================================================
|
|
// BATCH 9: Validation & Error Handling Tests (Coverage Improvement)
|
|
// ============================================================================
|
|
|
|
TEST_CASE("Validation - Empty query and hash", "[search][validation][error]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "";
|
|
req.hash = "";
|
|
req.limit = 10;
|
|
|
|
// Both query and hash empty should fail validation
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
REQUIRE_FALSE(result);
|
|
REQUIRE(result.error().code == yams::ErrorCode::InvalidArgument);
|
|
REQUIRE(result.error().message.find("required") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("Validation - Limit too high", "[search][validation][error]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "test";
|
|
req.limit = 200000; // > 100000 max
|
|
req.type = "keyword";
|
|
|
|
// Limit exceeding maximum should fail validation
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
REQUIRE_FALSE(result);
|
|
REQUIRE(result.error().code == yams::ErrorCode::InvalidArgument);
|
|
REQUIRE(result.error().message.find("out of allowed range") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("Validation - Invalid hash format (not hex)", "[search][validation][error]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "";
|
|
req.hash = "not-a-hex-string!!";
|
|
req.limit = 10;
|
|
|
|
// Non-hex hash should fail validation
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
REQUIRE_FALSE(result);
|
|
REQUIRE(result.error().code == yams::ErrorCode::InvalidArgument);
|
|
REQUIRE(result.error().message.find("Invalid hash format") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("Validation - Invalid hash format (too short)", "[search][validation][error]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "";
|
|
req.hash = "abc123"; // < 8 chars
|
|
req.limit = 10;
|
|
|
|
// Hash too short should fail validation
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
REQUIRE_FALSE(result);
|
|
REQUIRE(result.error().code == yams::ErrorCode::InvalidArgument);
|
|
REQUIRE(result.error().message.find("Invalid hash format") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("Validation - Invalid hash format (too long)", "[search][validation][error]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "";
|
|
req.hash = std::string(65, 'a'); // 65 chars, > 64 max
|
|
req.limit = 10;
|
|
|
|
// Hash too long should fail validation
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
REQUIRE_FALSE(result);
|
|
REQUIRE(result.error().code == yams::ErrorCode::InvalidArgument);
|
|
REQUIRE(result.error().message.find("Invalid hash format") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("HashSearch - Valid hash prefix", "[search][hash]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
// Create a document and get its hash
|
|
fixture.createCodeDocument("src/hashtest.cpp", "int main() { return 0; }", {"code"});
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "";
|
|
req.hash = "abc12345"; // Valid 8-char hex
|
|
req.limit = 10;
|
|
|
|
// Valid hash format should not fail validation (but may return NotFound)
|
|
auto result = runAwait(fixture.searchService()->search(req));
|
|
// Either succeeds with results or fails with NotFound (not InvalidArgument)
|
|
if (!result) {
|
|
REQUIRE(result.error().code != yams::ErrorCode::InvalidArgument);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("EdgeCase - Very high limit (under threshold)", "[search][edge]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "pipeline";
|
|
req.limit = 99999; // Just under 100000 max
|
|
req.type = "keyword";
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
// Should succeed (not fail validation)
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
TEST_CASE("EdgeCase - Limit exactly at maximum", "[search][edge]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "pipeline";
|
|
req.limit = 100000; // Exactly at max
|
|
req.type = "keyword";
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
// Should succeed (not fail validation)
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
TEST_CASE("EdgeCase - Query with special regex characters", "[search][edge][regex]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.query = "test.file[0]"; // Contains regex special chars
|
|
req.type = "keyword";
|
|
req.limit = 10;
|
|
|
|
// Should handle gracefully without regex errors
|
|
auto resp = fixture.executeSearch(req);
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
TEST_CASE("Filtering - Path pattern with wildcards", "[search][filtering][path]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.type = "keyword";
|
|
req.query = "class";
|
|
req.pathPatterns = {"**/*.cpp", "src/**/*.hpp"};
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
// Should match files with .cpp or .hpp in src/
|
|
for (const auto& result : resp.results) {
|
|
bool matches = result.path.find(".cpp") != std::string::npos ||
|
|
result.path.find(".hpp") != std::string::npos;
|
|
if (!matches && result.path.find("src/") == 0) {
|
|
matches = true; // Allow src/ paths
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Filtering - Multiple tags with matchAll=true", "[search][filtering][tags]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.type = "keyword";
|
|
req.query = "pipeline";
|
|
req.tags = {"code", "working"};
|
|
req.matchAllTags = true;
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
// Results should have both tags
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
TEST_CASE("Filtering - Extension filter", "[search][filtering][extension]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.type = "keyword";
|
|
req.query = "class";
|
|
req.extension = ".cpp";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
// All results should be .cpp files
|
|
for (const auto& result : resp.results) {
|
|
REQUIRE(result.path.find(".cpp") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Filtering - MIME type filter", "[search][filtering][mime]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.type = "keyword";
|
|
req.query = "pipeline";
|
|
req.mimeType = "text/";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
// Should only return text files
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
TEST_CASE("Filtering - File type filter (text only)", "[search][filtering][filetype]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.type = "keyword";
|
|
req.query = "class";
|
|
req.fileType = "text";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
// Should only return text files (not binary)
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
TEST_CASE("Filtering - File type filter (binary only)", "[search][filtering][filetype]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.type = "keyword";
|
|
req.query = "data";
|
|
req.fileType = "binary";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
// Should only return binary files
|
|
REQUIRE(resp.results.size() >= 0);
|
|
}
|
|
|
|
TEST_CASE("Facets - File type distribution", "[search][facets]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.type = "keyword";
|
|
req.query = "class";
|
|
req.limit = 100;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
REQUIRE(resp.results.size() > 0);
|
|
REQUIRE(resp.facets.size() >= 1);
|
|
|
|
bool foundExtensionFacet = false;
|
|
for (const auto& facet : resp.facets) {
|
|
if (facet.name == "extension") {
|
|
foundExtensionFacet = true;
|
|
REQUIRE(facet.values.size() > 0);
|
|
REQUIRE(facet.displayName == "File Type");
|
|
|
|
bool foundCpp = false;
|
|
for (const auto& fv : facet.values) {
|
|
if (fv.value == ".cpp") {
|
|
foundCpp = true;
|
|
REQUIRE(fv.count > 0);
|
|
REQUIRE(fv.value == fv.display);
|
|
}
|
|
}
|
|
REQUIRE(foundCpp);
|
|
break;
|
|
}
|
|
}
|
|
REQUIRE(foundExtensionFacet);
|
|
}
|
|
|
|
TEST_CASE("Facets - Empty results returns empty facets", "[search][facets]") {
|
|
SKIP_HYBRID_ON_WINDOWS();
|
|
SearchServiceFixture fixture;
|
|
|
|
app::services::SearchRequest req;
|
|
req.type = "keyword";
|
|
req.query = "zzzznonexistentterm12345";
|
|
req.limit = 10;
|
|
|
|
auto resp = fixture.executeSearch(req);
|
|
|
|
REQUIRE(resp.results.size() == 0);
|
|
}
|
|
|
|
// NOTE: 13 advanced hybrid search tests were previously here but have been removed.
|
|
// These tests used old mock-based architecture (ControllableVectorIndex, ControllableKeywordEngine)
|
|
// that is incompatible with the current PIMPL architecture.
|
|
// The active semantic and hybrid search tests have been migrated to:
|
|
// tests/integration/search/hybrid_search_integration_test.cpp
|
|
// If additional hybrid search functionality testing is needed, new tests should be written
|
|
// using the integration test infrastructure with DaemonHarness.
|
|
#endif // defined(_WIN32) && YAMS_ORT_API_VERSION < 23
|