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:
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user