test: add Catch2 unit test suite (gallery, calibration, tracking, similarity)

GPU-free, model-free tests for the pure logic: gallery HDF5 save/load
round-trips (actors, embeddings, embedded calibration) and legacy JSON
read back-compat; the calibration sigmoid fit, boundary inversion, and the
in-memory hash-keyed cache reuse/staleness; TrackGallery's diversity-buffer
eviction, novelty/spread safety gates, and promotion; FaceTracker's IoU/
embedding association and cross-cut track revival; and the GEMM similarity
backend (forced to CPU so the suite runs without a GPU).

Verified: all 39 test cases / 1640 assertions pass (cmake -DSAE_BUILD_TESTS=ON).
This commit is contained in:
2026-07-19 19:10:57 +02:00
parent 6f0ad83a55
commit 76df2f66aa
7 changed files with 816 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
// Unit tests for gallery (de)serialisation: HDF5 round-trip fidelity (the only
// format save_gallery writes), legacy JSON read back-compat (optional field
// defaults, the legacy "jellyfin_person_id" fallback). GPU-free, model-free.
#include <catch2/catch_test_macros.hpp>
#include "gallery/gallery_store.hpp"
#include "types.hpp"
#include <cstdio>
#include <fstream>
#include <string>
#include <nlohmann/json.hpp>
namespace {
struct TempFile {
std::string path;
explicit TempFile(const std::string& name)
: path(std::string(std::tmpnam(nullptr)) + name) {}
~TempFile() { std::remove(path.c_str()); }
};
Embedding make_embedding(float base) {
Embedding e{};
for (int i = 0; i < 512; ++i) e[i] = base + i * 1e-4f;
return e;
}
} // namespace
TEST_CASE("gallery save/load round-trips actors and embeddings", "[gallery]") {
ActorGallery g;
ActorGallery::Actor a;
a.imdb_id = "nm0000093";
a.tmdb_id = "287";
a.jellyfin_id = "guid-abc";
a.name = "Brad Pitt";
a.source_images = {"img1.jpg", "img2.jpg"};
a.embeddings = {make_embedding(0.1f), make_embedding(-0.2f)};
g.actors.push_back(a);
ActorGallery::Actor b;
b.name = "Edward Norton";
b.embeddings = {make_embedding(0.5f)};
g.actors.push_back(b);
// save_gallery always writes HDF5 (see gallery_store.cpp); use a .h5 path
// directly rather than relying on the "wrong extension gets rewritten"
// fallback, which is a compatibility shim, not the intended usage.
TempFile tf("gallery.h5");
save_gallery(tf.path, g);
ActorGallery loaded = load_gallery(tf.path);
REQUIRE(loaded.actors.size() == 2);
const auto& la = loaded.actors[0];
CHECK(la.imdb_id == "nm0000093");
CHECK(la.tmdb_id == "287");
CHECK(la.jellyfin_id == "guid-abc");
CHECK(la.name == "Brad Pitt");
CHECK(la.source_images == std::vector<std::string>{"img1.jpg", "img2.jpg"});
REQUIRE(la.embeddings.size() == 2);
for (int i = 0; i < 512; ++i) {
CHECK(la.embeddings[0][i] == a.embeddings[0][i]);
CHECK(la.embeddings[1][i] == a.embeddings[1][i]);
}
CHECK(loaded.actors[1].name == "Edward Norton");
CHECK(loaded.actors[1].embeddings.size() == 1);
}
TEST_CASE("gallery save/load round-trips calibration", "[gallery]") {
ActorGallery g;
ActorGallery::Actor a;
a.name = "Solo Actor";
a.embeddings = {make_embedding(0.1f)};
g.actors.push_back(a);
g.calib_a = 12.5f;
g.calib_b = -3.25f;
g.calib_valid = true;
g.calib_hash = 0xDEADBEEFULL;
TempFile tf("gallery_calib.h5");
save_gallery(tf.path, g);
ActorGallery loaded = load_gallery(tf.path);
CHECK(loaded.calib_a == g.calib_a);
CHECK(loaded.calib_b == g.calib_b);
CHECK(loaded.calib_valid == g.calib_valid);
CHECK(loaded.calib_hash == g.calib_hash);
}
TEST_CASE("gallery with no calibration loads with calib_hash 0", "[gallery]") {
ActorGallery g;
ActorGallery::Actor a;
a.name = "No Calibration";
a.embeddings = {make_embedding(0.f)};
g.actors.push_back(a);
// calib_hash left at its default (0) — save_gallery skips writing the
// /calibration group entirely when there's nothing to persist yet.
TempFile tf("gallery_nocalib.h5");
save_gallery(tf.path, g);
ActorGallery loaded = load_gallery(tf.path);
CHECK(loaded.calib_hash == 0);
CHECK_FALSE(loaded.calib_valid);
}
TEST_CASE("load_gallery defaults optional string fields to empty", "[gallery]") {
// Minimal actor: only the required name + embeddings.
nlohmann::json j;
j["actors"] = nlohmann::json::array();
nlohmann::json ja;
ja["name"] = "Minimal";
ja["embeddings"] = nlohmann::json::array();
ja["embeddings"].push_back(std::vector<float>(512, 0.25f));
j["actors"].push_back(ja);
TempFile tf("gallery_min.json");
{ std::ofstream out(tf.path); out << j.dump(); }
ActorGallery g = load_gallery(tf.path);
REQUIRE(g.actors.size() == 1);
CHECK(g.actors[0].name == "Minimal");
CHECK(g.actors[0].imdb_id.empty());
CHECK(g.actors[0].tmdb_id.empty());
CHECK(g.actors[0].jellyfin_id.empty());
CHECK(g.actors[0].source_images.empty());
REQUIRE(g.actors[0].embeddings.size() == 1);
CHECK(g.actors[0].embeddings[0][0] == 0.25f);
}
TEST_CASE("load_gallery reads the legacy jellyfin_person_id key", "[gallery]") {
nlohmann::json j;
j["actors"] = nlohmann::json::array();
nlohmann::json ja;
ja["name"] = "Legacy";
ja["jellyfin_person_id"] = "old-guid"; // pre-rename key
ja["embeddings"] = nlohmann::json::array();
ja["embeddings"].push_back(std::vector<float>(512, 0.f));
j["actors"].push_back(ja);
TempFile tf("gallery_legacy.json");
{ std::ofstream out(tf.path); out << j.dump(); }
ActorGallery g = load_gallery(tf.path);
REQUIRE(g.actors.size() == 1);
CHECK(g.actors[0].jellyfin_id == "old-guid");
}
TEST_CASE("load_gallery throws on a missing file", "[gallery]") {
CHECK_THROWS(load_gallery("/nonexistent/path/gallery.json"));
}