yams/tests/unit/detection/command_integration_test.cpp
Trevon d398556ee5
fix(tests): replace frozen expectations and harden release gates (#323)
* test(search,daemon,storage): assert behaviour instead of frozen literals

Several suites encoded exact internal values that legitimate tuning and
ranking changes are allowed to move: SCIENTIFIC-era profile weights and
rrfK values, corpus-statistic figures, and hardcoded timeout/cancellation
numbers. They failed on every platform after those changes landed, so the
real behaviour regressions they should catch were hidden behind noise.

Replace the frozen literals with contract assertions: weights are finite,
non-negative and normalize, state selection still holds, capability-gated
sources stay zeroed, and hybrid results keep matching canonical hash/path
sets. Deadlines are not widened and the daemon paths they cover are
unchanged.

Also add independent corpus and adapter oracles, deterministic collector
cancellation and late-result gates, and synchronize ServiceManager
shutdown observation, so the tests fail for the behaviour they name.

The portability allowlist keeps its two reviewed raw test-env reads; only
their line numbers moved.

Signed-off-by: trvon <git@trevon.dev>

* ci(tests): summarise Meson failures without flooding the log

A failing test run printed the whole Meson suite output, so the actual
failing test names and their assertion messages were buried and the
uploaded logs were dominated by passing-test chatter.

Add a bounded reporter that extracts the failing test names plus their
surrounding diagnostics, keeps an overall cap on emitted lines, and is
unit-tested against representative Meson output. Wire it into the Tests
workflow so failures stay readable.

Signed-off-by: trvon <git@trevon.dev>

* build(deb): validate candidate identity, mapping and upgrade ordering

The Debian packaging scripts took filenames and versions on trust, so a
misnamed candidate or a wrong package ordering could pass validation while
the real install path was never exercised.

Fail closed on candidate identity, version and architecture, verify the
package-to-source mapping, seed the legacy-only layout, wait on the actual
services, and assert install/upgrade/remove ordering for both Debian and
RPM in an offline container. Cases that cannot be proven on a host are
reported as skipped rather than silently passing.

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <git@trevon.dev>
2026-10-19 19:16:57 -06:00

226 lines
8.8 KiB
C++

#include <algorithm>
#include <array>
#include <cctype>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <initializer_list>
#include <span>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <yams/cli/yams_cli.h>
#include <yams/detection/file_type_detector.h>
#include "detection_test_helpers.h"
using namespace yams::detection;
using namespace yams::cli;
using yams::detection::test_utils::ScopedCurrentPath;
using yams::detection::test_utils::TestDirectory;
namespace fs = std::filesystem;
namespace {
struct CommandIntegrationFixture {
struct ExpectedFile {
fs::path path;
const char* mimeType;
const char* category;
bool isBinary;
};
CommandIntegrationFixture() : cwdGuard(dir.root()) {
magicFile =
dir.createDataFile("data/magic_numbers.json", R"({"version":"1.0","patterns":[]})");
// Include a complete PDF signature, not the ambiguous four-byte "%PDF" prefix.
createFile("document.pdf",
{std::byte{0x25}, std::byte{0x50}, std::byte{0x44}, std::byte{0x46},
std::byte{0x2D}, std::byte{0x31}, std::byte{0x2E}, std::byte{0x37},
std::byte{0x0A}},
"application/pdf", "document", true);
createFile("image.jpg", {std::byte{0xFF}, std::byte{0xD8}, std::byte{0xFF}}, "image/jpeg",
"image", true);
createFile("text.txt", {std::byte{0x48}, std::byte{0x65}, std::byte{0x6C}, std::byte{0x6C}},
"text/plain", "text", false);
cli = std::make_unique<YamsCLI>();
cli->setDataPath(dir.testDataDir());
}
~CommandIntegrationFixture() { FileTypeDetector::instance().clearCache(); }
void createFile(const std::string& name, std::initializer_list<std::byte> bytes,
const char* mimeType, const char* category, bool isBinary) {
auto path =
dir.createBinaryFile(name, std::span<const std::byte>(bytes.begin(), bytes.size()));
std::ofstream file(path, std::ios::app);
file << " additional content for testing";
testFiles.push_back({path, mimeType, category, isBinary});
}
FileTypeDetectorConfig detectorConfig() const {
FileTypeDetectorConfig config{};
auto magic = YamsCLI::findMagicNumbersFile();
config.patternsFile = magic;
config.useCustomPatterns = !magic.empty();
config.useBuiltinPatterns = true;
return config;
}
TestDirectory dir{"detection_cli"};
ScopedCurrentPath cwdGuard;
std::unique_ptr<YamsCLI> cli;
fs::path magicFile;
std::vector<ExpectedFile> testFiles;
};
} // namespace
TEST_CASE_METHOD(CommandIntegrationFixture, "findMagicNumbersFile resolves local data",
"[detection][cli]") {
auto foundPath = YamsCLI::findMagicNumbersFile();
REQUIRE_FALSE(foundPath.empty());
REQUIRE(fs::is_regular_file(foundPath));
CHECK(fs::equivalent(foundPath, magicFile));
}
TEST_CASE_METHOD(CommandIntegrationFixture, "findMagicNumbersFile respects search order",
"[detection][cli]") {
auto localMagic =
dir.createDataFile("data/magic_numbers.json", R"({"version":"1.0","patterns":[]})");
auto foundPath = YamsCLI::findMagicNumbersFile();
#if defined(__APPLE__)
// macOS: /var is a symlink to /private/var, so canonicalize both paths
std::error_code ec;
auto canonicalFound = fs::canonical(foundPath, ec);
auto canonicalLocal = fs::canonical(localMagic, ec);
CHECK(canonicalFound == canonicalLocal);
#elif defined(_WIN32)
auto normalizedPathString = [](const fs::path& path) {
std::error_code ec;
auto resolved = fs::weakly_canonical(path, ec);
if (ec) {
resolved = path.lexically_normal();
}
auto normalized = resolved.generic_string();
std::transform(normalized.begin(), normalized.end(), normalized.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return normalized;
};
CHECK(normalizedPathString(foundPath) == normalizedPathString(localMagic));
#else
// Linux: direct comparison
CHECK(foundPath == localMagic);
#endif
}
TEST_CASE_METHOD(CommandIntegrationFixture, "detector initializes from CLI config",
"[detection][cli]") {
auto config = detectorConfig();
auto result = FileTypeDetector::instance().initialize(config);
REQUIRE(result);
bool isText = FileTypeDetector::instance().isTextMimeType("text/plain");
bool isBinary = FileTypeDetector::instance().isBinaryMimeType("image/jpeg");
auto category = FileTypeDetector::instance().getFileTypeCategory("application/pdf");
CHECK(isText);
CHECK(isBinary);
CHECK_FALSE(category.empty());
}
TEST_CASE_METHOD(CommandIntegrationFixture, "detector falls back when JSON missing",
"[detection][cli]") {
FileTypeDetectorConfig config{};
config.patternsFile = fs::path("/nonexistent/path/magic_numbers.json");
config.useCustomPatterns = true;
config.useBuiltinPatterns = true;
auto initResult = FileTypeDetector::instance().initialize(config);
REQUIRE(initResult);
auto category = FileTypeDetector::instance().getFileTypeCategory("image/jpeg");
CHECK_FALSE(category.empty());
}
TEST_CASE_METHOD(CommandIntegrationFixture, "file detection handles CLI files",
"[detection][cli]") {
REQUIRE(FileTypeDetector::instance().initialize(detectorConfig()));
for (const auto& expected : testFiles) {
CAPTURE(expected.path);
auto result = FileTypeDetector::instance().detectFromFile(expected.path);
REQUIRE(result);
CHECK(result.value().mimeType == expected.mimeType);
CHECK(result.value().fileType == expected.category);
CHECK(result.value().isBinary == expected.isBinary);
}
}
TEST_CASE_METHOD(CommandIntegrationFixture, "stats-style filtering uses classification",
"[detection][cli]") {
REQUIRE(FileTypeDetector::instance().initialize(detectorConfig()));
for (const auto& expected : testFiles) {
CAPTURE(expected.path);
auto mimeType =
FileTypeDetector::getMimeTypeFromExtension(expected.path.extension().string());
CHECK(mimeType == expected.mimeType);
CHECK(FileTypeDetector::instance().getFileTypeCategory(mimeType) == expected.category);
CHECK(FileTypeDetector::instance().isBinaryMimeType(mimeType) == expected.isBinary);
}
}
TEST_CASE_METHOD(CommandIntegrationFixture, "list command filtering remains consistent",
"[detection][cli]") {
REQUIRE(FileTypeDetector::instance().initialize(detectorConfig()));
for (const auto& expected : testFiles) {
CAPTURE(expected.mimeType);
CHECK(FileTypeDetector::instance().isTextMimeType(expected.mimeType) == !expected.isBinary);
CHECK(FileTypeDetector::instance().isBinaryMimeType(expected.mimeType) ==
expected.isBinary);
CHECK(FileTypeDetector::instance().getFileTypeCategory(expected.mimeType) ==
expected.category);
}
CHECK(FileTypeDetector::instance().isTextMimeType("application/json"));
CHECK_FALSE(FileTypeDetector::instance().isBinaryMimeType("application/json"));
}
TEST_CASE_METHOD(CommandIntegrationFixture, "get command filtering aligns with detection",
"[detection][cli]") {
REQUIRE(FileTypeDetector::instance().initialize(detectorConfig()));
for (const auto& expected : testFiles) {
CAPTURE(expected.path);
REQUIRE(fs::is_regular_file(expected.path));
auto detectResult = FileTypeDetector::instance().detectFromFile(expected.path);
REQUIRE(detectResult);
const auto& sig = detectResult.value();
CHECK(sig.mimeType == expected.mimeType);
CHECK(sig.isBinary == expected.isBinary);
CHECK(sig.fileType == expected.category);
CHECK(FileTypeDetector::instance().isTextMimeType(sig.mimeType) == !expected.isBinary);
}
}
TEST_CASE_METHOD(CommandIntegrationFixture,
"missing optional patterns preserve MIME classification", "[detection][cli]") {
FileTypeDetectorConfig badConfig{};
badConfig.useLibMagic = false;
badConfig.useBuiltinPatterns = false;
badConfig.useCustomPatterns = true;
badConfig.patternsFile = dir.root() / "missing" / "magic_numbers.json";
REQUIRE_FALSE(fs::exists(badConfig.patternsFile));
// A missing optional pattern file is tolerated; extension classification remains usable.
auto result = FileTypeDetector::instance().initialize(badConfig);
REQUIRE(result);
auto mime = FileTypeDetector::getMimeTypeFromExtension(".txt");
CHECK(mime == "text/plain");
auto isText = FileTypeDetector::instance().isTextMimeType("text/plain");
auto isBinary = FileTypeDetector::instance().isBinaryMimeType("image/jpeg");
CHECK(isText);
CHECK(isBinary);
}