Some checks failed
Fuzz Smoke / libFuzzer smoke (push) Has been cancelled
* chore(warnings): default-initialize aggregate members left out of brace-init
GCC -Wmissing-field-initializers fired about 1300 times where tests and
src build structs with a short positional brace list. Give the omitted
members an empty default member initializer. Value-initialization from
{} is what aggregate init already did for them, so behavior is
unchanged.
Signed-off-by: trvon <me@trevon.dev>
* chore(warnings): remove or annotate unused parameters, functions and variables
- init_command, s3_signer test, request_handler_lifetime test: comment out
the unused parameter name.
- video_content_handler: path is only read under YAMS_HAVE_FFPROBE.
- vector_memory_scaling_bench: main() takes no arguments.
- daemon_status_render: delete unused neutralText helper.
- DaemonMetrics: mallocStackLoggingNoCompactEnabled is only called in the
__APPLE__ block, so define it there.
- SocketServer: einval_streak only matters in the __APPLE__ EINVAL path.
- onnx_plugin_harness test, vector_backend_engine_compare bench,
work_coordinator test: mark platform-conditional or busy-loop
variables [[maybe_unused]].
Signed-off-by: trvon <git@trevon.dev>
* chore(warnings): fix sign-compare, range-loop, nodiscard and dangling-ref in tests
- search_admission_controller: count started workers in std::uint32_t so
the comparison with the uint32 cap is same-signed.
- p2p_manager: iterate the invalid-host list by const reference.
- integrity_verifier: discard the nodiscard results the tests do not
inspect with static_cast<void>.
- post_ingest_queue: findNode/findEdge take std::string_view so no
temporary std::string is created for the call (GCC
-Wdangling-reference false positive on the returned reference).
- topology_ablation_bench: the doc comment ended a line with a backslash,
which continues the comment (-Wcomment); show the env export on its own
line.
Signed-off-by: trvon <git@trevon.dev>
* chore(warnings): drop redundant std::move of a const captured json
The coroutine lambda is not mutable, so the captured optional<json> is
const and std::move(*progressToken) already selected the copy assignment.
Drop the std::move so the code states what actually happens.
Signed-off-by: trvon <git@trevon.dev>
* chore(warnings): mark __int128 use in db_integrity_stamp as an extension
-Wpedantic flags __int128. Use a __extension__ typedef, the GCC/Clang
idiom for an intentional extension, instead of the bare keyword.
Signed-off-by: trvon <git@trevon.dev>
* chore(warnings): remove always-true unsigned >= 0 comparisons
GCC -Wtype-limits flagged 51 test assertions of the form
CHECK(unsignedValue >= 0) that can never fail, plus a dead
`totalCount.value() < 0` clause in the graph listByType handler (the
count is std::size_t). In the tests keep the evaluation as a
static_cast<void> so the accessors are still exercised; in the handler
drop the dead clause and keep the range check.
Signed-off-by: trvon <me@trevon.dev>
---------
Signed-off-by: trvon <me@trevon.dev>
Signed-off-by: trvon <git@trevon.dev>
1112 lines
43 KiB
C++
1112 lines
43 KiB
C++
// Copyright (c) 2025 YAMS Contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
// Migrated from GTest: search_service_test.cpp
|
|
// Full SearchService integration tests with DB lifecycle and async coroutines.
|
|
|
|
#include "src/app/services/glob_matcher.h"
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <catch2/matchers/catch_matchers_string.hpp>
|
|
#include <yams/app/services/extract_scope.h>
|
|
|
|
#ifdef _WIN32
|
|
#include <process.h>
|
|
#define getpid _getpid
|
|
#endif
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstddef>
|
|
#include <filesystem>
|
|
#include <optional>
|
|
#include <span>
|
|
#include <thread>
|
|
#include <unordered_set>
|
|
|
|
#include <spdlog/spdlog.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/api/content_store_builder.h>
|
|
#include <yams/app/services/services.hpp>
|
|
#include <yams/metadata/connection_pool.h>
|
|
#include <yams/metadata/database.h>
|
|
#include <yams/metadata/knowledge_graph_store.h>
|
|
#include <yams/metadata/metadata_repository.h>
|
|
#include <yams/metadata/migration.h>
|
|
#include <yams/search/search_engine.h>
|
|
#include <yams/search/search_execution_context.h>
|
|
|
|
#include "common/fixture_manager.h"
|
|
#include "common/test_data_generator.h"
|
|
#include "common/test_helpers_catch2.h"
|
|
|
|
using namespace yams;
|
|
using namespace yams::app::services;
|
|
using namespace yams::metadata;
|
|
using namespace yams::api;
|
|
using Catch::Matchers::ContainsSubstring;
|
|
|
|
namespace {
|
|
|
|
constexpr std::size_t kZeroTotal = 0;
|
|
|
|
// ---------- Mock repositories (same as GTest originals) ----------
|
|
|
|
class FlakyMetadataRepository : public MetadataRepository {
|
|
public:
|
|
explicit FlakyMetadataRepository(ConnectionPool& pool) : MetadataRepository(pool) {}
|
|
|
|
void setGetDocumentByHashFailures(std::size_t count) { getDocumentByHashFailures_ = count; }
|
|
|
|
Result<std::optional<DocumentInfo>> getDocumentByHash(const std::string& hash) override {
|
|
if (yams::test::consume_failure(getDocumentByHashFailures_)) {
|
|
return Error{ErrorCode::NotInitialized, "metadata warming up"};
|
|
}
|
|
return MetadataRepository::getDocumentByHash(hash);
|
|
}
|
|
|
|
private:
|
|
std::size_t getDocumentByHashFailures_{0};
|
|
};
|
|
|
|
class TagLookupCountingRepository : public MetadataRepository {
|
|
public:
|
|
explicit TagLookupCountingRepository(ConnectionPool& pool) : MetadataRepository(pool) {}
|
|
|
|
Result<std::unordered_map<std::string, MetadataValue>>
|
|
getAllMetadata(int64_t documentId) override {
|
|
++allMetadataCalls;
|
|
return MetadataRepository::getAllMetadata(documentId);
|
|
}
|
|
|
|
Result<std::unordered_map<int64_t, std::vector<std::string>>>
|
|
batchGetDocumentTags(std::span<const int64_t> documentIds) override {
|
|
++batchTagCalls;
|
|
if (failTagLookup)
|
|
return Error{ErrorCode::InternalError, "injected tag lookup failure"};
|
|
return MetadataRepository::batchGetDocumentTags(documentIds);
|
|
}
|
|
|
|
std::atomic<int> allMetadataCalls{0};
|
|
std::atomic<int> batchTagCalls{0};
|
|
bool failTagLookup{false};
|
|
};
|
|
|
|
class SlowSnippetMetadataRepository : public MetadataRepository {
|
|
public:
|
|
SlowSnippetMetadataRepository(ConnectionPool& pool, std::chrono::milliseconds delay)
|
|
: MetadataRepository(pool), delay_(delay) {}
|
|
|
|
Result<std::optional<DocumentContent>> getContent(int64_t documentId) override {
|
|
std::this_thread::sleep_for(delay_);
|
|
return MetadataRepository::getContent(documentId);
|
|
}
|
|
|
|
Result<std::unordered_map<int64_t, DocumentContent>>
|
|
batchGetContent(const std::vector<int64_t>& documentIds) override {
|
|
std::this_thread::sleep_for(delay_);
|
|
return MetadataRepository::batchGetContent(documentIds);
|
|
}
|
|
|
|
Result<yams::metadata::SearchResults>
|
|
search(const std::string& query, int limit = 50, int offset = 0,
|
|
const std::optional<std::vector<int64_t>>& docIds = std::nullopt) override {
|
|
auto result = MetadataRepository::search(query, limit, offset, docIds);
|
|
if (result) {
|
|
for (auto& entry : result.value().results) {
|
|
entry.snippet.clear();
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private:
|
|
std::chrono::milliseconds delay_;
|
|
};
|
|
|
|
// ---------- Async helper ----------
|
|
|
|
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"});
|
|
}
|
|
|
|
// ---------- Fixture ----------
|
|
|
|
struct SearchServiceFixture {
|
|
SearchServiceFixture() {
|
|
auto pid = std::to_string(::getpid());
|
|
auto timestamp =
|
|
std::to_string(std::chrono::system_clock::now().time_since_epoch().count());
|
|
testDir = std::filesystem::temp_directory_path() /
|
|
("search_service_catch2_" + pid + "_" + timestamp);
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(testDir, ec);
|
|
REQUIRE_FALSE(ec);
|
|
|
|
fixtureManager = std::make_unique<yams::test::FixtureManager>(testDir / "fixtures");
|
|
|
|
// DB
|
|
dbPath = std::filesystem::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);
|
|
|
|
KnowledgeGraphStoreConfig kgConfig;
|
|
kgConfig.enable_wal = false;
|
|
auto kgResult = makeSqliteKnowledgeGraphStore(*pool, kgConfig);
|
|
REQUIRE(kgResult);
|
|
kgStore = std::shared_ptr<KnowledgeGraphStore>(std::move(kgResult).value());
|
|
metadataRepo->setKnowledgeGraphStore(kgStore);
|
|
|
|
MigrationManager mm(*database);
|
|
auto initResult = mm.initialize();
|
|
REQUIRE(initResult);
|
|
mm.registerMigrations(YamsMetadataMigrations::getAllMigrations());
|
|
auto migrateResult = mm.migrate();
|
|
REQUIRE(migrateResult);
|
|
|
|
// Content store
|
|
ContentStoreBuilder builder;
|
|
auto storeResult = builder.withStoragePath(testDir / "storage")
|
|
.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());
|
|
|
|
searchEngine = nullptr;
|
|
|
|
appContext.store = contentStore;
|
|
appContext.metadataRepo = metadataRepo;
|
|
appContext.searchEngine = searchEngine;
|
|
appContext.kgStore = kgStore;
|
|
workerGuard.emplace(workerIo.get_executor());
|
|
workerThread = std::thread([this]() { workerIo.run(); });
|
|
appContext.workerExecutor = workerIo.get_executor();
|
|
searchService = makeSearchService(appContext);
|
|
|
|
// Test data
|
|
yams::test::TestDataGenerator generator(1337);
|
|
testDocuments = {
|
|
{generator.generateMarkdown(2, "Artificial Intelligence Primer"), "ai.txt"},
|
|
{generator.generateMarkdown(2, "Python Programming Tutorial"), "python.txt"},
|
|
{generator.generateMarkdown(2, "Database Design Handbook"), "database.md"},
|
|
{generator.generateMarkdown(2, "Web Development Guide"), "web.md"},
|
|
{generator.generateMarkdown(3, "Climate Research Summary"), "climate.txt"}};
|
|
|
|
auto docService = makeDocumentService(appContext);
|
|
for (const auto& [content, filename] : testDocuments) {
|
|
auto fixture =
|
|
fixtureManager->createTextFixture(filename, content, {"search", "unit", "fixture"});
|
|
StoreDocumentRequest storeReq;
|
|
storeReq.path = fixture.path.string();
|
|
auto storeResult2 = docService->store(storeReq);
|
|
if (storeResult2) {
|
|
testHashes.push_back(storeResult2.value().hash);
|
|
indexDocumentContent(storeResult2.value().hash, filename, content);
|
|
}
|
|
}
|
|
}
|
|
|
|
~SearchServiceFixture() {
|
|
// Cleanup order matters: services first, then stores, then DB
|
|
searchService.reset();
|
|
searchEngine.reset();
|
|
appContext.searchEngine.reset();
|
|
workerGuard.reset();
|
|
if (workerThread.joinable()) {
|
|
workerThread.join();
|
|
}
|
|
appContext.workerExecutor = {};
|
|
appContext.kgStore.reset();
|
|
appContext.store.reset();
|
|
appContext.metadataRepo.reset();
|
|
kgStore.reset();
|
|
contentStore.reset();
|
|
metadataRepo.reset();
|
|
fixtureManager.reset();
|
|
if (database) {
|
|
database->close();
|
|
database.reset();
|
|
}
|
|
if (pool) {
|
|
pool->shutdown();
|
|
pool.reset();
|
|
}
|
|
if (!testDir.empty() && std::filesystem::exists(testDir)) {
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(testDir, ec);
|
|
}
|
|
}
|
|
|
|
void indexDocumentContent(const std::string& hash, const std::string& title,
|
|
const std::string& content) {
|
|
auto docResult = metadataRepo->getDocumentByHash(hash);
|
|
if (!docResult || !docResult.value().has_value())
|
|
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: {}", indexResult.error().message);
|
|
}
|
|
|
|
void setMetadataForHash(const std::string& hash, const std::string& key,
|
|
const std::string& value) {
|
|
auto docResult = metadataRepo->getDocumentByHash(hash);
|
|
REQUIRE(docResult);
|
|
REQUIRE(docResult.value().has_value());
|
|
auto result = metadataRepo->setMetadata(docResult.value()->id, key, MetadataValue(value));
|
|
REQUIRE(result);
|
|
}
|
|
|
|
SearchRequest createBasicSearchRequest(const std::string& query) {
|
|
SearchRequest request;
|
|
request.query = query;
|
|
request.limit = 10;
|
|
request.globalSearch = true;
|
|
return request;
|
|
}
|
|
|
|
void seedRelationsForResult(const SearchItem& item) const {
|
|
REQUIRE(kgStore != nullptr);
|
|
REQUIRE_FALSE(item.path.empty());
|
|
REQUIRE_FALSE(item.hash.empty());
|
|
|
|
KGNode fileNode;
|
|
fileNode.nodeKey = "path:file:" + item.path;
|
|
fileNode.type = std::string("file");
|
|
fileNode.label = item.fileName.empty() ? item.path : item.fileName;
|
|
auto fileNodeId = kgStore->upsertNode(fileNode);
|
|
REQUIRE(fileNodeId.has_value());
|
|
|
|
KGNode docNode;
|
|
docNode.nodeKey = "doc:" + item.hash;
|
|
docNode.type = std::string("document");
|
|
docNode.label = item.fileName.empty() ? item.path : item.fileName;
|
|
auto docNodeId = kgStore->upsertNode(docNode);
|
|
REQUIRE(docNodeId.has_value());
|
|
|
|
KGNode symbolOne;
|
|
symbolOne.nodeKey = "symbol:test:one:" + item.hash;
|
|
symbolOne.type = std::string("symbol");
|
|
symbolOne.label = std::string("SymbolOne");
|
|
auto symbolOneId = kgStore->upsertNode(symbolOne);
|
|
REQUIRE(symbolOneId.has_value());
|
|
|
|
KGNode symbolTwo;
|
|
symbolTwo.nodeKey = "symbol:test:two:" + item.hash;
|
|
symbolTwo.type = std::string("symbol");
|
|
symbolTwo.label = std::string("SymbolTwo");
|
|
auto symbolTwoId = kgStore->upsertNode(symbolTwo);
|
|
REQUIRE(symbolTwoId.has_value());
|
|
|
|
KGEdge versionEdge;
|
|
versionEdge.srcNodeId = fileNodeId.value();
|
|
versionEdge.dstNodeId = docNodeId.value();
|
|
versionEdge.relation = "has-version";
|
|
REQUIRE(kgStore->addEdge(versionEdge).has_value());
|
|
|
|
KGEdge definesOne;
|
|
definesOne.srcNodeId = docNodeId.value();
|
|
definesOne.dstNodeId = symbolOneId.value();
|
|
definesOne.relation = "defines";
|
|
REQUIRE(kgStore->addEdge(definesOne).has_value());
|
|
|
|
KGEdge definesTwo;
|
|
definesTwo.srcNodeId = docNodeId.value();
|
|
definesTwo.dstNodeId = symbolTwoId.value();
|
|
definesTwo.relation = "defines";
|
|
REQUIRE(kgStore->addEdge(definesTwo).has_value());
|
|
}
|
|
|
|
std::filesystem::path testDir;
|
|
std::filesystem::path dbPath;
|
|
std::unique_ptr<Database> database;
|
|
std::unique_ptr<ConnectionPool> pool;
|
|
std::shared_ptr<MetadataRepository> metadataRepo;
|
|
std::shared_ptr<KnowledgeGraphStore> kgStore;
|
|
std::shared_ptr<IContentStore> contentStore;
|
|
std::shared_ptr<search::SearchEngine> searchEngine;
|
|
boost::asio::io_context workerIo;
|
|
std::optional<boost::asio::executor_work_guard<boost::asio::io_context::executor_type>>
|
|
workerGuard;
|
|
std::thread workerThread;
|
|
AppContext appContext;
|
|
std::shared_ptr<ISearchService> searchService;
|
|
std::unique_ptr<yams::test::FixtureManager> fixtureManager;
|
|
std::vector<std::pair<std::string, std::string>> testDocuments;
|
|
std::vector<std::string> testHashes;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
// ============ Basic Search Tests ============
|
|
|
|
TEST_CASE("SearchService: basic text search", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("programming");
|
|
request.showHash = true;
|
|
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
|
|
const auto& resp = result.value();
|
|
CHECK(resp.total >= kZeroTotal);
|
|
CHECK(resp.executionTimeMs >= 0);
|
|
REQUIRE(resp.searchStats.contains("metadata_operations"));
|
|
|
|
for (const auto& doc : resp.results) {
|
|
CHECK_FALSE(doc.hash.empty());
|
|
CHECK(doc.score >= 0.0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchService: host cancellation stops the search", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto cancelled = std::make_shared<std::atomic<bool>>(true);
|
|
auto ctx = f.appContext;
|
|
ctx.cancellationSignal = cancelled;
|
|
auto service = makeSearchService(ctx);
|
|
|
|
auto request = f.createBasicSearchRequest("programming");
|
|
auto result = runAwait(service->search(request));
|
|
REQUIRE_FALSE(result);
|
|
CHECK((result.error().code == ErrorCode::OperationCancelled));
|
|
|
|
cancelled->store(false);
|
|
auto resumed = runAwait(service->search(request));
|
|
REQUIRE(resumed);
|
|
}
|
|
|
|
TEST_CASE("SearchService: a lines: qualifier shapes result snippets",
|
|
"[unit][services][search][extract-scope]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("programming lines:1-1");
|
|
request.showHash = true;
|
|
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
const auto& resp = result.value();
|
|
REQUIRE_FALSE(resp.results.empty());
|
|
|
|
for (const auto& item : resp.results) {
|
|
std::string expected;
|
|
for (const auto& [content, filename] : f.testDocuments) {
|
|
if (item.path.ends_with(filename)) {
|
|
expected = content.substr(0, content.find('\n'));
|
|
}
|
|
}
|
|
REQUIRE_FALSE(expected.empty());
|
|
CHECK((item.snippet == expected));
|
|
}
|
|
REQUIRE(resp.searchStats.contains("extract_scope"));
|
|
CHECK_THAT(resp.searchStats.at("extract_scope"),
|
|
Catch::Matchers::ContainsSubstring("lines:1-1 applied to"));
|
|
}
|
|
|
|
TEST_CASE("SearchService: relation metadata enrichment", "[unit][services][search][relations]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("artificial");
|
|
request.showHash = true;
|
|
|
|
auto baseline = runAwait(f.searchService->search(request));
|
|
REQUIRE(baseline);
|
|
REQUIRE_FALSE(baseline.value().results.empty());
|
|
|
|
const auto seedItem = baseline.value().results.front();
|
|
f.seedRelationsForResult(seedItem);
|
|
|
|
auto enriched = runAwait(f.searchService->search(request));
|
|
REQUIRE(enriched);
|
|
|
|
auto it = std::find_if(enriched.value().results.begin(), enriched.value().results.end(),
|
|
[&](const SearchItem& item) { return item.hash == seedItem.hash; });
|
|
REQUIRE(it != enriched.value().results.end());
|
|
|
|
REQUIRE(it->metadata.contains("relation_count"));
|
|
// 3 seeded edges + 1 auto-created path hierarchy edge from document storage
|
|
CHECK(std::stoi(it->metadata.at("relation_count")) >= 3);
|
|
REQUIRE(it->metadata.contains("relation_types"));
|
|
CHECK_THAT(it->metadata.at("relation_types"), ContainsSubstring("defines:2"));
|
|
REQUIRE(it->metadata.contains("relation_summary"));
|
|
CHECK_THAT(it->metadata.at("relation_summary"), ContainsSubstring("defines(2)"));
|
|
REQUIRE(it->metadata.contains("primary_relation"));
|
|
CHECK(it->metadata.at("primary_relation") == "defines");
|
|
|
|
REQUIRE(enriched.value().searchStats.contains("relation_results_enriched"));
|
|
CHECK(enriched.value().searchStats.at("relation_results_enriched") != "0");
|
|
}
|
|
|
|
TEST_CASE("SearchService: multiple terms ranked by relevance", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("python programming tutorial");
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
|
|
if (result.value().results.size() > 1) {
|
|
auto& docs = result.value().results;
|
|
for (size_t i = 1; i < docs.size(); ++i) {
|
|
CHECK(docs[i - 1].score >= docs[i].score);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchService: case-insensitive search", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto r1 = runAwait(f.searchService->search(f.createBasicSearchRequest("ARTIFICIAL")));
|
|
auto r2 = runAwait(f.searchService->search(f.createBasicSearchRequest("artificial")));
|
|
auto r3 = runAwait(f.searchService->search(f.createBasicSearchRequest("Artificial")));
|
|
REQUIRE(r1);
|
|
REQUIRE(r2);
|
|
REQUIRE(r3);
|
|
CHECK(r1.value().total == r2.value().total);
|
|
CHECK(r2.value().total == r3.value().total);
|
|
}
|
|
|
|
// ============ Advanced Search Features ============
|
|
|
|
TEST_CASE("SearchService: limit", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("test");
|
|
request.limit = 3;
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
CHECK(result.value().results.size() <= 3);
|
|
}
|
|
|
|
TEST_CASE("SearchService: offset", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto r1 = runAwait(f.searchService->search(f.createBasicSearchRequest("test")));
|
|
REQUIRE(r1);
|
|
if (r1.value().total > 1) {
|
|
auto r2 = runAwait(f.searchService->search(f.createBasicSearchRequest("test")));
|
|
REQUIRE(r2);
|
|
static_cast<void>(r2.value().results.size());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchService: fuzzy search", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("progamming"); // intentional typo
|
|
request.fuzzy = true;
|
|
request.similarity = 0.8f;
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
CHECK(result.value().total >= kZeroTotal);
|
|
}
|
|
|
|
// ============ Search Filters ============
|
|
|
|
TEST_CASE("SearchService: tag filter", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("programming");
|
|
request.tags = {"tutorial", "example"};
|
|
request.matchAllTags = false;
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
CHECK(result.value().total >= kZeroTotal);
|
|
}
|
|
|
|
TEST_CASE("SearchService: tag filtering loads tags once per request, not per document",
|
|
"[unit][services][search][tags]") {
|
|
SearchServiceFixture f;
|
|
REQUIRE(f.testHashes.size() >= 3);
|
|
f.setMetadataForHash(f.testHashes[0], "tag", "tutorial");
|
|
f.setMetadataForHash(f.testHashes[1], "tag:example", "1");
|
|
f.setMetadataForHash(f.testHashes[1], "tag:tutorial", "1");
|
|
|
|
auto counting = std::make_shared<TagLookupCountingRepository>(*f.pool);
|
|
f.metadataRepo = counting;
|
|
f.appContext.metadataRepo = counting;
|
|
f.searchService = makeSearchService(f.appContext);
|
|
|
|
SECTION("tag lookup failure is reported rather than an empty successful search") {
|
|
counting->failTagLookup = true;
|
|
for (bool byHash : {true, false}) {
|
|
auto request = f.createBasicSearchRequest(byHash ? "" : "*.txt");
|
|
if (byHash)
|
|
request.hash = f.testHashes[1].substr(0, 12);
|
|
request.tags = {"tutorial"};
|
|
auto result = runAwait(f.searchService->search(request));
|
|
CHECK_FALSE(result.has_value());
|
|
if (!result)
|
|
CHECK(result.error().message == "injected tag lookup failure");
|
|
}
|
|
}
|
|
SECTION("hash prefix search") {
|
|
auto request = f.createBasicSearchRequest("");
|
|
request.hash = f.testHashes[1].substr(0, 12);
|
|
request.tags = {"tutorial", "example"};
|
|
request.matchAllTags = true;
|
|
auto result = runAwait(f.searchService->search(request));
|
|
INFO(std::string(result ? "ok" : result.error().message.c_str()));
|
|
REQUIRE(result);
|
|
CHECK(result.value().results.size() == 1);
|
|
CHECK(counting->batchTagCalls.load() == 1);
|
|
CHECK(counting->allMetadataCalls.load() == 0);
|
|
}
|
|
SECTION("path search") {
|
|
auto request = f.createBasicSearchRequest("*.txt");
|
|
request.tags = {"tutorial"};
|
|
request.matchAllTags = false;
|
|
auto result = runAwait(f.searchService->search(request));
|
|
INFO(std::string(result ? "ok" : result.error().message.c_str()));
|
|
REQUIRE(result);
|
|
CHECK(result.value().results.size() == 2);
|
|
CHECK(counting->batchTagCalls.load() == 1);
|
|
CHECK(counting->allMetadataCalls.load() == 0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchService: file type filter", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("development");
|
|
request.fileType = "text";
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
|
|
for (const auto& doc : result.value().results) {
|
|
if (!doc.mimeType.empty()) {
|
|
CHECK((doc.mimeType.find("text/") == 0 || doc.mimeType.find("application/json") == 0 ||
|
|
doc.mimeType.find("application/xml") == 0));
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchService: extension filter", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("tutorial");
|
|
request.extension = ".txt";
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
|
|
for (const auto& doc : result.value().results) {
|
|
if (!doc.fileName.empty()) {
|
|
CHECK_THAT(doc.fileName, ContainsSubstring(".txt"));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============ Search Types ============
|
|
|
|
TEST_CASE("SearchService: keyword search", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("artificial intelligence");
|
|
request.type = "keyword";
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
CHECK(result.value().total >= kZeroTotal);
|
|
for (const auto& doc : result.value().results) {
|
|
CHECK(doc.score > 0.0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchService: explicit keyword search does not auto-fallback to fuzzy",
|
|
"[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("xyzzyveryunlikelytomatchanything");
|
|
request.type = "keyword";
|
|
request.fuzzy = false;
|
|
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
CHECK(result.value().total == kZeroTotal);
|
|
CHECK(result.value().results.empty());
|
|
CHECK(result.value().type == "full-text");
|
|
}
|
|
|
|
TEST_CASE(
|
|
"SearchService: literal-looking queries default to keyword retrieval without fuzzy fallback",
|
|
"[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("resolveGraphCandidateNodeId");
|
|
request.fuzzy = false;
|
|
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
CHECK(result.value().total == kZeroTotal);
|
|
CHECK(result.value().results.empty());
|
|
CHECK(result.value().type == "full-text");
|
|
REQUIRE(result.value().searchStats.contains("retrieval_mode"));
|
|
CHECK(result.value().searchStats.at("retrieval_mode") == "literal");
|
|
CHECK(result.value().searchStats.at("effective_type") == "keyword");
|
|
REQUIRE(result.value().searchStats.contains("lexical_delta_pending_docs"));
|
|
CHECK(result.value().searchStats.at("lexical_delta_pending_docs") == "0");
|
|
}
|
|
|
|
TEST_CASE("SearchService: explicit keyword key=value query uses metadata filters",
|
|
"[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
REQUIRE(f.testHashes.size() >= 2);
|
|
f.setMetadataForHash(f.testHashes[0], "task", "performance");
|
|
f.setMetadataForHash(f.testHashes[0], "owner", "opencode");
|
|
f.setMetadataForHash(f.testHashes[1], "task", "performance");
|
|
f.setMetadataForHash(f.testHashes[1], "owner", "someone-else");
|
|
|
|
auto request = f.createBasicSearchRequest("task=performance owner=opencode");
|
|
request.type = "keyword";
|
|
request.fuzzy = false;
|
|
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
CHECK(result.value().type == "metadata");
|
|
CHECK(result.value().total >= 1);
|
|
REQUIRE_FALSE(result.value().results.empty());
|
|
const bool matchedExpectedHash =
|
|
std::any_of(result.value().results.begin(), result.value().results.end(),
|
|
[&](const SearchItem& item) { return item.hash == f.testHashes[0]; });
|
|
CHECK(matchedExpectedHash);
|
|
}
|
|
|
|
TEST_CASE("SearchService: semantic search", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("machine learning algorithms");
|
|
request.type = "semantic";
|
|
auto result = runAwait(f.searchService->search(request));
|
|
// Semantic search might not be available
|
|
if (result) {
|
|
CHECK(result.value().total >= kZeroTotal);
|
|
for (const auto& doc : result.value().results) {
|
|
CHECK(doc.score > 0.0);
|
|
}
|
|
} else {
|
|
CHECK_FALSE(result);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchService: hybrid search", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto engine = search::createSearchEngine(f.metadataRepo, nullptr, nullptr, f.kgStore, {});
|
|
REQUIRE(engine);
|
|
f.searchEngine = std::shared_ptr<search::SearchEngine>(std::move(engine));
|
|
f.appContext.searchEngine = f.searchEngine;
|
|
f.searchService = makeSearchService(f.appContext);
|
|
|
|
auto request = f.createBasicSearchRequest("python programming");
|
|
request.type = "hybrid";
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
CHECK(result.value().total >= kZeroTotal);
|
|
CHECK_FALSE(result.value().searchStats.contains("symbol_rank_requested"));
|
|
for (const auto& doc : result.value().results) {
|
|
CHECK(doc.score > 0.0);
|
|
}
|
|
}
|
|
|
|
// ============ Error Handling ============
|
|
|
|
TEST_CASE("SearchService: empty query", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto result = runAwait(f.searchService->search(f.createBasicSearchRequest("")));
|
|
REQUIRE_FALSE(result);
|
|
CHECK(result.error().code == ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
TEST_CASE("SearchService: invalid limit", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("test");
|
|
request.limit = -1;
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE_FALSE(result);
|
|
CHECK(result.error().code == ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
TEST_CASE("SearchService: invalid search type", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("test");
|
|
request.type = "invalid_type";
|
|
auto result = runAwait(f.searchService->search(request));
|
|
if (!result) {
|
|
CHECK(result.error().code == ErrorCode::InvalidArgument);
|
|
} else {
|
|
CHECK(result.value().total >= kZeroTotal);
|
|
}
|
|
}
|
|
|
|
// ============ Performance ============
|
|
|
|
TEST_CASE("SearchService: search performance", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("programming tutorial example");
|
|
auto start = std::chrono::high_resolution_clock::now();
|
|
auto result = runAwait(f.searchService->search(request));
|
|
auto end = std::chrono::high_resolution_clock::now();
|
|
REQUIRE(result);
|
|
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
|
|
CHECK(duration.count() < 200);
|
|
}
|
|
|
|
TEST_CASE("SearchService: large result set performance", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("test");
|
|
request.limit = 100;
|
|
auto start = std::chrono::high_resolution_clock::now();
|
|
auto result = runAwait(f.searchService->search(request));
|
|
auto end = std::chrono::high_resolution_clock::now();
|
|
REQUIRE(result);
|
|
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
|
|
CHECK(duration.count() < 200);
|
|
}
|
|
|
|
// ============ Special Queries ============
|
|
|
|
TEST_CASE("SearchService: special characters", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto result = runAwait(f.searchService->search(f.createBasicSearchRequest("C++ programming")));
|
|
REQUIRE(result);
|
|
CHECK(result.value().total >= kZeroTotal);
|
|
}
|
|
|
|
TEST_CASE("SearchService: quoted phrase", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto result =
|
|
runAwait(f.searchService->search(f.createBasicSearchRequest("\"machine learning\"")));
|
|
REQUIRE(result);
|
|
CHECK(result.value().total >= kZeroTotal);
|
|
}
|
|
|
|
TEST_CASE("SearchService: wildcards", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto result = runAwait(f.searchService->search(f.createBasicSearchRequest("program*")));
|
|
REQUIRE(result);
|
|
CHECK(result.value().total >= kZeroTotal);
|
|
}
|
|
|
|
// ============ Result Quality ============
|
|
|
|
TEST_CASE("SearchService: relevance scoring", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("python programming tutorial");
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
if (result.value().results.size() > 1) {
|
|
auto& docs = result.value().results;
|
|
for (size_t i = 1; i < docs.size(); ++i) {
|
|
CHECK(docs[i - 1].score >= docs[i].score);
|
|
}
|
|
if (!docs.empty()) {
|
|
CHECK(docs[0].score > 0.0);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchService: no results", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto result = runAwait(
|
|
f.searchService->search(f.createBasicSearchRequest("xyzzyveryunlikelytomatchanything")));
|
|
REQUIRE(result);
|
|
CHECK(result.value().total == kZeroTotal);
|
|
CHECK(result.value().results.empty());
|
|
}
|
|
|
|
// ============ Integration ============
|
|
|
|
TEST_CASE("SearchService: context integration", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
REQUIRE(f.searchService != nullptr);
|
|
auto result = runAwait(f.searchService->search(f.createBasicSearchRequest("test")));
|
|
REQUIRE(result);
|
|
}
|
|
|
|
TEST_CASE("SearchService: host execution context supplies steady-state readiness",
|
|
"[unit][services][search][readiness]") {
|
|
SearchServiceFixture f;
|
|
f.appContext.searchExecutionContextProvider = [] {
|
|
auto context = search::defaultSearchExecutionContext();
|
|
context.freshness.lexicalReady = true;
|
|
context.freshness.vectorReady = true;
|
|
return context;
|
|
};
|
|
f.searchService = makeSearchService(f.appContext);
|
|
|
|
auto request = f.createBasicSearchRequest("programming");
|
|
request.type = "keyword";
|
|
auto result = runAwait(f.searchService->search(request));
|
|
|
|
REQUIRE(result);
|
|
REQUIRE(result.value().searchStats.contains("search_engine_ready"));
|
|
CHECK(result.value().searchStats.at("search_engine_ready") == "true");
|
|
CHECK(result.value().searchStats.at("vector_ready") == "true");
|
|
CHECK(result.value().searchStats.at("corpus_warming") == "false");
|
|
}
|
|
|
|
// ============ Edge Cases ============
|
|
|
|
TEST_CASE("SearchService: very long query", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
std::string longQuery(1000, 'a');
|
|
auto result = runAwait(f.searchService->search(f.createBasicSearchRequest(longQuery)));
|
|
if (result) {
|
|
CHECK(result.value().total >= kZeroTotal);
|
|
} else {
|
|
CHECK(result.error().code == ErrorCode::InvalidArgument);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchService: paths only", "[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("programming");
|
|
request.pathsOnly = true;
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
CHECK_FALSE(result.value().paths.empty());
|
|
for (const auto& doc : result.value().results) {
|
|
CHECK_FALSE(doc.hash.empty());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SearchService: snippet hydration exports bounded stage telemetry",
|
|
"[unit][services][search][hydration][profiling]") {
|
|
SearchServiceFixture f;
|
|
auto slowRepo =
|
|
std::make_shared<SlowSnippetMetadataRepository>(*f.pool, std::chrono::milliseconds{25});
|
|
slowRepo->setKnowledgeGraphStore(f.kgStore);
|
|
f.metadataRepo = slowRepo;
|
|
f.appContext.metadataRepo = slowRepo;
|
|
f.searchService = makeSearchService(f.appContext);
|
|
|
|
auto request = f.createBasicSearchRequest("programming");
|
|
request.type = "keyword";
|
|
request.snippetHydrationTimeoutMs = 1;
|
|
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
const auto& stats = result.value().searchStats;
|
|
REQUIRE(stats.contains("timing_snippet_hydration_us"));
|
|
REQUIRE(stats.contains("timing_snippet_lookup_us"));
|
|
REQUIRE(stats.contains("timing_snippet_content_fetch_us"));
|
|
REQUIRE(stats.contains("snippet_hydration_candidates"));
|
|
REQUIRE(stats.contains("snippet_hydration_missing"));
|
|
CHECK(std::stoll(stats.at("timing_snippet_hydration_us")) >=
|
|
std::stoll(stats.at("timing_snippet_content_fetch_us")));
|
|
CHECK(stats.at("snippet_timeout_hit") == "true");
|
|
}
|
|
|
|
TEST_CASE("SearchService: snippet hydration centers query matches beyond file prefixes",
|
|
"[unit][services][search][hydration]") {
|
|
SearchServiceFixture f;
|
|
const std::string query = "graph affected tests traversal implementation";
|
|
const std::string content = std::string(12'000, 'x') +
|
|
"\n// graph affected tests traversal implementation\n"
|
|
"int affectedTests() { return traverseGraph(); }\n";
|
|
auto fixture =
|
|
f.fixtureManager->createTextFixture("graph_context_service.cpp", content, {"search"});
|
|
auto documentService = makeDocumentService(f.appContext);
|
|
StoreDocumentRequest storeRequest;
|
|
storeRequest.path = fixture.path.string();
|
|
auto stored = documentService->store(storeRequest);
|
|
REQUIRE(stored);
|
|
f.indexDocumentContent(stored.value().hash, "graph_context_service.cpp", content);
|
|
|
|
auto missingSnippetRepo =
|
|
std::make_shared<SlowSnippetMetadataRepository>(*f.pool, std::chrono::milliseconds{0});
|
|
missingSnippetRepo->setKnowledgeGraphStore(f.kgStore);
|
|
f.metadataRepo = missingSnippetRepo;
|
|
f.appContext.metadataRepo = missingSnippetRepo;
|
|
f.searchService = makeSearchService(f.appContext);
|
|
|
|
auto request = f.createBasicSearchRequest(query);
|
|
request.type = "keyword";
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
const auto match = std::find_if(
|
|
result.value().results.begin(), result.value().results.end(), [](const SearchItem& item) {
|
|
return item.path.find("graph_context_service.cpp") != std::string::npos;
|
|
});
|
|
REQUIRE((match != result.value().results.end()));
|
|
CHECK((match->snippet.find("affectedTests") != std::string::npos));
|
|
CHECK((match->snippet.find(std::string(80, 'x')) == std::string::npos));
|
|
CHECK((result.value().searchStats.at("snippet_query_centered_results") == "1"));
|
|
CHECK((result.value().searchStats.at("snippet_query_fallback_results") == "0"));
|
|
}
|
|
|
|
TEST_CASE("SearchService: lightIndex retries transient metadata errors",
|
|
"[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
REQUIRE_FALSE(f.testHashes.empty());
|
|
|
|
auto flakyRepo = std::make_shared<FlakyMetadataRepository>(*f.pool);
|
|
flakyRepo->setGetDocumentByHashFailures(1);
|
|
|
|
f.metadataRepo = flakyRepo;
|
|
f.appContext.metadataRepo = flakyRepo;
|
|
f.searchService = makeSearchService(f.appContext);
|
|
|
|
auto result = f.searchService->lightIndexForHash(f.testHashes.front(), 512 * 1024);
|
|
REQUIRE(result);
|
|
}
|
|
|
|
TEST_CASE("SearchService: extension facet telemetry is populated for local keyword searches",
|
|
"[unit][services][search]") {
|
|
SearchServiceFixture f;
|
|
auto request = f.createBasicSearchRequest("Artificial");
|
|
request.type = "keyword";
|
|
|
|
auto result = runAwait(f.searchService->search(request));
|
|
REQUIRE(result);
|
|
|
|
const auto& response = result.value();
|
|
REQUIRE(response.searchStats.contains("facet_workers_used"));
|
|
REQUIRE(response.searchStats.contains("facet_input_count"));
|
|
REQUIRE(response.searchStats.contains("facet_sample_count"));
|
|
REQUIRE(response.searchStats.contains("facet_approximate"));
|
|
CHECK(response.searchStats.at("facet_approximate") == "false");
|
|
CHECK(response.searchStats.at("budget_short_query") == "true");
|
|
}
|
|
|
|
TEST_CASE("GlobMatcher keeps the search service glob semantics", "[unit][services][glob]") {
|
|
using yams::app::services::GlobMatcher;
|
|
// '*' stays inside a segment, '**' crosses, '?' is one character, dots are literal.
|
|
CHECK(GlobMatcher("**/*.md").matches("docs/guide/intro.md"));
|
|
CHECK_FALSE(GlobMatcher("*.md").matches("docs/intro.md"));
|
|
CHECK(GlobMatcher("*.md").matches("intro.md"));
|
|
CHECK(GlobMatcher("src/**/main.cpp").matches("src/a/b/main.cpp"));
|
|
CHECK_FALSE(GlobMatcher("src/*/main.cpp").matches("src/a/b/main.cpp"));
|
|
CHECK(GlobMatcher("file?.txt").matches("file1.txt"));
|
|
CHECK_FALSE(GlobMatcher("a.b").matches("aXb"));
|
|
// Regex metacharacters in a glob are literal, so brackets never open a class.
|
|
CHECK(GlobMatcher("**/[x].md").matches("a/[x].md"));
|
|
CHECK_FALSE(GlobMatcher("[x].md").matches("x.md"));
|
|
// The compiled form is reused: matching many texts against one pattern stays correct.
|
|
const GlobMatcher shared("**/*.cpp");
|
|
for (int i = 0; i < 100; ++i) {
|
|
CHECK(shared.matches("src/" + std::to_string(i) + "/file.cpp"));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// lines:/pages:/section:/selector: qualifiers were parsed from the query and then ignored:
|
|
// the query header promised "downstream snippet shaping" and nothing applied it.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
namespace {
|
|
|
|
yams::app::services::SearchItem scopeItem(std::string hash, std::string path, std::string mime) {
|
|
yams::app::services::SearchItem item;
|
|
item.hash = std::move(hash);
|
|
item.path = std::move(path);
|
|
item.mimeType = std::move(mime);
|
|
item.snippet = "original snippet";
|
|
return item;
|
|
}
|
|
|
|
std::vector<std::byte> scopeBytes(std::string_view text) {
|
|
std::vector<std::byte> out(text.size());
|
|
std::memcpy(out.data(), text.data(), text.size());
|
|
return out;
|
|
}
|
|
|
|
yams::search::ExtractScope scopeOf(yams::search::ExtractScopeType type, std::string range = {}) {
|
|
yams::search::ExtractScope scope;
|
|
scope.type = type;
|
|
scope.range = std::move(range);
|
|
return scope;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Extract scope: lines:<range> shapes each result's snippet to those lines",
|
|
"[unit][services][search][extract-scope]") {
|
|
using yams::app::services::applyExtractScope;
|
|
std::vector<yams::app::services::SearchItem> results{
|
|
scopeItem("text", "notes/a.md", "text/markdown"),
|
|
scopeItem("pdf", "papers/b.pdf", "application/pdf"),
|
|
scopeItem("huge", "logs/c.txt", "text/plain"),
|
|
};
|
|
auto read = [](const std::string& hash) -> yams::Result<std::vector<std::byte>> {
|
|
if (hash == "text") {
|
|
return scopeBytes("one\ntwo\nthree\nfour\n");
|
|
}
|
|
if (hash == "pdf") {
|
|
return scopeBytes("%PDF-1.7 binary");
|
|
}
|
|
return scopeBytes(std::string(64, 'x'));
|
|
};
|
|
|
|
const auto outcome = applyExtractScope(
|
|
results, scopeOf(yams::search::ExtractScopeType::Lines, "2-3"), read, /*maxBytes=*/32);
|
|
|
|
CHECK((results[0].snippet == "two\nthree"));
|
|
// No line-range handler for PDF, and the third exceeds the read cap: both keep their
|
|
// snippet and are counted, never silently presented as shaped.
|
|
CHECK((results[1].snippet == "original snippet"));
|
|
CHECK((results[2].snippet == "original snippet"));
|
|
CHECK((outcome.shaped == 1U));
|
|
CHECK((outcome.unshaped == 2U));
|
|
CHECK_THAT(outcome.note, Catch::Matchers::ContainsSubstring("lines:2-3 applied to 1 of 3"));
|
|
}
|
|
|
|
TEST_CASE("Extract scope: an invalid line range is reported, not applied",
|
|
"[unit][services][search][extract-scope]") {
|
|
std::vector<yams::app::services::SearchItem> results{
|
|
scopeItem("text", "notes/a.md", "text/markdown")};
|
|
auto read = [](const std::string&) -> yams::Result<std::vector<std::byte>> {
|
|
return scopeBytes("one\ntwo\n");
|
|
};
|
|
const auto outcome = yams::app::services::applyExtractScope(
|
|
results, scopeOf(yams::search::ExtractScopeType::Lines, "two-five"), read);
|
|
CHECK((results[0].snippet == "original snippet"));
|
|
CHECK((outcome.shaped == 0U));
|
|
CHECK_THAT(outcome.note, Catch::Matchers::ContainsSubstring("invalid"));
|
|
}
|
|
|
|
TEST_CASE("Extract scope: pages, section and selector are reported as not applied",
|
|
"[unit][services][search][extract-scope]") {
|
|
std::vector<yams::app::services::SearchItem> results{
|
|
scopeItem("text", "notes/a.md", "text/markdown")};
|
|
bool read = false;
|
|
auto reader = [&](const std::string&) -> yams::Result<std::vector<std::byte>> {
|
|
read = true;
|
|
return scopeBytes("one\n");
|
|
};
|
|
const auto outcome = yams::app::services::applyExtractScope(
|
|
results, scopeOf(yams::search::ExtractScopeType::Pages, "1-2"), reader);
|
|
CHECK_FALSE(read);
|
|
CHECK((results[0].snippet == "original snippet"));
|
|
CHECK_THAT(outcome.note, Catch::Matchers::ContainsSubstring("pages:1-2"));
|
|
CHECK_THAT(outcome.note, Catch::Matchers::ContainsSubstring("not applied"));
|
|
}
|
|
|
|
TEST_CASE("Extract scope: a query without a scope qualifier changes nothing",
|
|
"[unit][services][search][extract-scope]") {
|
|
std::vector<yams::app::services::SearchItem> results{
|
|
scopeItem("text", "notes/a.md", "text/markdown")};
|
|
bool read = false;
|
|
auto reader = [&](const std::string&) -> yams::Result<std::vector<std::byte>> {
|
|
read = true;
|
|
return scopeBytes("one\n");
|
|
};
|
|
const auto outcome =
|
|
yams::app::services::applyExtractScope(results, yams::search::ExtractScope{}, reader);
|
|
CHECK_FALSE(read);
|
|
CHECK((results[0].snippet == "original snippet"));
|
|
CHECK(outcome.note.empty());
|
|
}
|