yams/tests/plugins/zyp/zyp_plugin_catch2_test.cpp
Trevon 6d8877a6d4
feat(search): add Simeon bandit tuning (#33)
* feat(search): per-profile simeon bandit with training-free self-tuning

* fix(zyp): free parallel buffers correctly

* fix: code cleanup and simeon version bump

* fix(search): guard Simeon bandit state
2026-05-17 04:08:50 -04:00

505 lines
15 KiB
C++

/**
* @file zyp_plugin_test.cpp
* @brief Unit tests for zyp (Zig YAMS PDF) plugin
*/
#include <catch2/catch_test_macros.hpp>
#include <yams/compat/dlfcn.h>
#include <cstdio>
#include <cstring>
#include <map>
#include <string>
#include <vector>
extern "C" {
#include <yams/plugins/abi.h>
#include <yams/plugins/content_extractor_v1.h>
}
namespace {
/**
* Build a minimal valid PDF with text content.
* This creates the smallest possible valid PDF for testing.
*/
std::vector<uint8_t> buildMinimalPdf(const std::string& text = "Hello World") {
// Minimal PDF structure
std::string pdf = R"(%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length )" + std::to_string(44 + text.size()) +
R"( >>
stream
BT
/F1 12 Tf
100 700 Td
()" + text + R"() Tj
ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000266 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
)";
// Calculate xref offset (approximate)
size_t xrefOffset = pdf.size() - 10; // Before "startxref\n"
pdf += std::to_string(xrefOffset) + "\n%%EOF\n";
return {pdf.begin(), pdf.end()};
}
/**
* Build a PDF with metadata.
*/
std::vector<uint8_t> buildPdfWithMetadata() {
std::string pdf = R"(%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT
/F1 12 Tf
100 700 Td
(Test) Tj
ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
6 0 obj
<< /Title (Test Document)
/Author (Test Author)
/Subject (Unit Testing)
/Keywords (test, pdf, zyp)
/Creator (zyp_plugin_test)
/Producer (YAMS Test Suite)
/CreationDate (D:20240101120000)
>>
endobj
xref
0 7
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000266 00000 n
0000000360 00000 n
0000000430 00000 n
trailer
<< /Size 7 /Root 1 0 R /Info 6 0 R >>
startxref
650
%%EOF
)";
return {pdf.begin(), pdf.end()};
}
std::vector<uint8_t> buildTwoPagePdf() {
std::string pdf = "%PDF-1.4\n";
std::vector<size_t> offsets;
auto addObject = [&](int number, const std::string& body) {
offsets.push_back(pdf.size());
pdf += std::to_string(number) + " 0 obj\n" + body + "\nendobj\n";
};
addObject(1, "<< /Type /Catalog /Pages 2 0 R >>");
addObject(2, "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>");
addObject(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
"/Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >>");
addObject(4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
"/Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>");
const std::string firstStream = "BT\n/F1 12 Tf\n100 700 Td\n(First Page Text) Tj\nET\n";
const std::string secondStream = "BT\n/F1 12 Tf\n100 700 Td\n(Second Page Text) Tj\nET\n";
addObject(5, "<< /Length " + std::to_string(firstStream.size()) + " >>\nstream\n" +
firstStream + "endstream");
addObject(6, "<< /Length " + std::to_string(secondStream.size()) + " >>\nstream\n" +
secondStream + "endstream");
addObject(7, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
const size_t xrefOffset = pdf.size();
pdf += "xref\n0 " + std::to_string(offsets.size() + 1) + "\n";
pdf += "0000000000 65535 f \n";
for (size_t offset : offsets) {
char entry[24]{};
std::snprintf(entry, sizeof(entry), "%010zu 00000 n \n", offset);
pdf += entry;
}
pdf += "trailer\n<< /Size " + std::to_string(offsets.size() + 1) +
" /Root 1 0 R >>\nstartxref\n" + std::to_string(xrefOffset) + "\n%%EOF\n";
return {pdf.begin(), pdf.end()};
}
struct ZypPluginTest {
void* handle_ = nullptr;
yams_content_extractor_v1* extractor_ = nullptr;
ZypPluginTest() {
#ifdef __APPLE__
const char* paths[] = {ZYP_PLUGIN_PATH, "builddir/plugins/zyp/yams_zyp.dylib",
"plugins/zyp/yams_zyp.dylib", nullptr};
#elif defined(_WIN32)
const char* paths[] = {ZYP_PLUGIN_PATH, "builddir/plugins/zyp/yams_zyp.dll",
"plugins/zyp/yams_zyp.dll", nullptr};
#else
const char* paths[] = {ZYP_PLUGIN_PATH, "builddir/plugins/zyp/yams_zyp.so",
"plugins/zyp/yams_zyp.so", nullptr};
#endif
for (const char** p = paths; *p; ++p) {
handle_ = dlopen(*p, RTLD_LAZY | RTLD_LOCAL);
if (handle_)
break;
}
if (!handle_) {
return;
}
auto getInterface = reinterpret_cast<int (*)(const char*, uint32_t, void**)>(
dlsym(handle_, "yams_plugin_get_interface"));
if (!getInterface) {
dlclose(handle_);
handle_ = nullptr;
return;
}
void* iface = nullptr;
int rc = getInterface(YAMS_IFACE_CONTENT_EXTRACTOR_V1_ID,
YAMS_IFACE_CONTENT_EXTRACTOR_V1_VERSION, &iface);
if (rc == YAMS_PLUGIN_OK && iface) {
extractor_ = static_cast<yams_content_extractor_v1*>(iface);
}
}
~ZypPluginTest() {
if (handle_) {
dlclose(handle_);
handle_ = nullptr;
}
extractor_ = nullptr;
}
ZypPluginTest(const ZypPluginTest&) = delete;
ZypPluginTest& operator=(const ZypPluginTest&) = delete;
bool pluginAvailable() const { return handle_ != nullptr && extractor_ != nullptr; }
};
// ============================================================================
// Plugin Loading Tests
// ============================================================================
TEST_CASE_METHOD(ZypPluginTest, "PluginLoads", "[plugin][zyp]") {
if (!handle_) {
SKIP("zyp plugin not available (requires Zig build)");
}
CHECK(handle_ != nullptr);
}
TEST_CASE_METHOD(ZypPluginTest, "AbiVersion", "[plugin][zyp]") {
if (!handle_) {
SKIP("zyp plugin not available");
}
auto getAbiVersion = reinterpret_cast<int (*)()>(dlsym(handle_, "yams_plugin_get_abi_version"));
REQUIRE(getAbiVersion != nullptr);
CHECK(getAbiVersion() == YAMS_PLUGIN_ABI_VERSION);
}
TEST_CASE_METHOD(ZypPluginTest, "PluginName", "[plugin][zyp]") {
if (!handle_) {
SKIP("zyp plugin not available");
}
auto getName = reinterpret_cast<const char* (*)()>(dlsym(handle_, "yams_plugin_get_name"));
REQUIRE(getName != nullptr);
CHECK(std::string(getName()) == "zyp");
}
TEST_CASE_METHOD(ZypPluginTest, "PluginVersion", "[plugin][zyp]") {
if (!handle_) {
SKIP("zyp plugin not available");
}
auto getVersion =
reinterpret_cast<const char* (*)()>(dlsym(handle_, "yams_plugin_get_version"));
REQUIRE(getVersion != nullptr);
CHECK(std::string(getVersion()) == "1.0.0");
}
TEST_CASE_METHOD(ZypPluginTest, "InterfaceRetrieval", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
CHECK(extractor_ != nullptr);
CHECK(extractor_->abi_version ==
static_cast<uint32_t>(YAMS_IFACE_CONTENT_EXTRACTOR_V1_VERSION));
}
// ============================================================================
// supports() Tests
// ============================================================================
TEST_CASE_METHOD(ZypPluginTest, "SupportsPdfMimeType", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
CHECK(extractor_->supports("application/pdf", nullptr));
CHECK(extractor_->supports("application/pdf", ".pdf"));
}
TEST_CASE_METHOD(ZypPluginTest, "SupportsPdfExtension", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
CHECK(extractor_->supports(nullptr, ".pdf"));
CHECK(extractor_->supports(nullptr, "pdf"));
}
TEST_CASE_METHOD(ZypPluginTest, "DoesNotSupportOtherTypes", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
CHECK_FALSE(extractor_->supports("text/plain", nullptr));
CHECK_FALSE(extractor_->supports("application/json", nullptr));
CHECK_FALSE(extractor_->supports(nullptr, ".txt"));
CHECK_FALSE(extractor_->supports(nullptr, ".docx"));
}
TEST_CASE_METHOD(ZypPluginTest, "SupportsNullArgs", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
// Both null should return false
CHECK_FALSE(extractor_->supports(nullptr, nullptr));
}
// ============================================================================
// extract() Tests
// ============================================================================
TEST_CASE_METHOD(ZypPluginTest, "ExtractMinimalPdf", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
auto pdf = buildMinimalPdf("Hello World");
yams_extraction_result_t* result = nullptr;
int rc = extractor_->extract(pdf.data(), pdf.size(), &result);
// May fail if zpdf can't parse the minimal PDF
if (rc != YAMS_PLUGIN_OK) {
SKIP("zpdf couldn't parse minimal test PDF");
}
REQUIRE(result != nullptr);
REQUIRE(result->text != nullptr);
// Note: zpdf may not extract text from our minimal synthetic PDF
// since it lacks proper font encoding. Just verify we get a result.
// Real PDFs will have actual text extraction.
// Check metadata contains page_count (may be 0 for minimal PDFs)
bool hasPageCount = false;
for (size_t i = 0; i < result->metadata.count; ++i) {
if (std::strcmp(result->metadata.pairs[i].key, "page_count") == 0) {
hasPageCount = true;
// Accept any non-negative page count
int pageCount = std::atoi(result->metadata.pairs[i].value);
CHECK(pageCount >= 0);
}
}
CHECK(hasPageCount);
extractor_->free_result(result);
}
TEST_CASE_METHOD(ZypPluginTest, "ExtractPdfWithMetadata", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
auto pdf = buildPdfWithMetadata();
yams_extraction_result_t* result = nullptr;
int rc = extractor_->extract(pdf.data(), pdf.size(), &result);
if (rc != YAMS_PLUGIN_OK) {
SKIP("zpdf couldn't parse test PDF with metadata");
}
REQUIRE(result != nullptr);
// Check for metadata fields
std::map<std::string, std::string> meta;
for (size_t i = 0; i < result->metadata.count; ++i) {
meta[result->metadata.pairs[i].key] = result->metadata.pairs[i].value;
}
// Title should be extracted
if (meta.count("pdf_title")) {
CHECK(meta["pdf_title"] == "Test Document");
}
if (meta.count("pdf_author")) {
CHECK(meta["pdf_author"] == "Test Author");
}
extractor_->free_result(result);
}
TEST_CASE_METHOD(ZypPluginTest, "ExtractMultiPagePdfRepeatedly", "[plugin][zyp][memory]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
auto pdf = buildTwoPagePdf();
for (int i = 0; i < 10; ++i) {
INFO("iteration=" << i);
yams_extraction_result_t* result = nullptr;
int rc = extractor_->extract(pdf.data(), pdf.size(), &result);
REQUIRE(rc == YAMS_PLUGIN_OK);
REQUIRE(result != nullptr);
REQUIRE(result->text != nullptr);
std::string text = result->text;
CHECK(text.find("First Page Text") != std::string::npos);
CHECK(text.find("Second Page Text") != std::string::npos);
extractor_->free_result(result);
}
}
// ============================================================================
// Error Handling Tests
// ============================================================================
TEST_CASE_METHOD(ZypPluginTest, "ExtractNullContent", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
yams_extraction_result_t* result = nullptr;
int rc = extractor_->extract(nullptr, 0, &result);
CHECK(rc == YAMS_PLUGIN_ERR_INVALID);
}
TEST_CASE_METHOD(ZypPluginTest, "ExtractEmptyContent", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
uint8_t empty = 0;
yams_extraction_result_t* result = nullptr;
int rc = extractor_->extract(&empty, 0, &result);
CHECK(rc == YAMS_PLUGIN_ERR_INVALID);
}
TEST_CASE_METHOD(ZypPluginTest, "ExtractInvalidPdf", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
// Not a valid PDF
std::string notPdf = "This is not a PDF file";
yams_extraction_result_t* result = nullptr;
int rc = extractor_->extract(reinterpret_cast<const uint8_t*>(notPdf.data()), notPdf.size(),
&result);
// zpdf may either return an error OR return success with empty text
// Both behaviors are acceptable for invalid input
if (rc == YAMS_PLUGIN_OK) {
// If success, verify we got a valid (possibly empty) result
REQUIRE(result != nullptr);
REQUIRE(result->text != nullptr);
extractor_->free_result(result);
} else {
// If error, it should be INVALID
CHECK(rc == YAMS_PLUGIN_ERR_INVALID);
}
}
TEST_CASE_METHOD(ZypPluginTest, "ExtractNullResult", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
auto pdf = buildMinimalPdf();
int rc = extractor_->extract(pdf.data(), pdf.size(), nullptr);
CHECK(rc == YAMS_PLUGIN_ERR_INVALID);
}
// ============================================================================
// Memory Management Tests
// ============================================================================
TEST_CASE_METHOD(ZypPluginTest, "FreeResultNull", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
// Should not crash
extractor_->free_result(nullptr);
}
TEST_CASE_METHOD(ZypPluginTest, "FreeResultMultipleTimes", "[plugin][zyp]") {
if (!pluginAvailable()) {
SKIP("zyp plugin not available");
}
auto pdf = buildMinimalPdf();
yams_extraction_result_t* result = nullptr;
int rc = extractor_->extract(pdf.data(), pdf.size(), &result);
if (rc != YAMS_PLUGIN_OK) {
SKIP("zpdf couldn't parse minimal test PDF");
}
// First free should work
extractor_->free_result(result);
// Second free with null should be safe
extractor_->free_result(nullptr);
}
} // anonymous namespace