diff options
| author | Marcos Magueta <maguetamarcos@gmail.com> | 2026-05-18 12:50:25 -0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-18 12:50:25 -0300 |
| commit | af8759a9c1d965a3220024d1857aea219d62d898 (patch) | |
| tree | 9cf14af855cac3674f508ec001bd04e262a524ae /test | |
| parent | 0fc76b99abbff1d3cef8e2f9fcd56f3ae4f23a7a (diff) | |
| download | RNT-af8759a9c1d965a3220024d1857aea219d62d898.tar.gz RNT-af8759a9c1d965a3220024d1857aea219d62d898.zip | |
Branching and object registry rework (#11)
Diffstat (limited to 'test')
| -rw-r--r-- | test/C_API.cpp | 442 | ||||
| -rw-r--r-- | test/CursorManager.cpp | 160 | ||||
| -rw-r--r-- | test/LifecycleManager.cpp | 185 | ||||
| -rw-r--r-- | test/MultigroupCodec.cpp | 145 | ||||
| -rw-r--r-- | test/NamespaceReferenceManager.cpp | 203 | ||||
| -rw-r--r-- | test/ObjectManager.cpp | 75 | ||||
| -rw-r--r-- | test/VM.cpp | 12 |
7 files changed, 1218 insertions, 4 deletions
diff --git a/test/C_API.cpp b/test/C_API.cpp new file mode 100644 index 0000000..f186374 --- /dev/null +++ b/test/C_API.cpp @@ -0,0 +1,442 @@ +#include <catch2/catch_test_macros.hpp> + +#include "RNT_C_API.h" + +#include <cstring> +#include <string> + +// --------------------------------------------------------------------------- +// End-to-end C API tests covering step 2 (Branch hash-pointer), step 3+4 +// (copy-on-write writes and resolver wiring through rnt_open_handle), and +// step 5 (session lifecycle and overrides). +// +// The C API holds a single process-global runtime; rnt_init is idempotent. +// Each test uses unique branch and session identifiers so state from one +// test does not affect another. +// --------------------------------------------------------------------------- + +namespace +{ + struct InitGuard { + InitGuard() { REQUIRE(rnt_init("memory", nullptr) == 0); } + }; + + std::string take_string(char* p) + { + std::string s = p ? p : ""; + rnt_free_string(p); + return s; + } +} + +// --------------------------------------------------------------------------- +// Step 2 — Branch hash-pointer model +// --------------------------------------------------------------------------- + +TEST_CASE("rnt_register_branch with empty target creates an unborn branch", + "[step2][capi][branch]") +{ + InitGuard _; + const char* path = "/system/branches/test_step2_unborn"; + REQUIRE(rnt_register_branch(path, "") == 0); + + rnt_handle_t h = rnt_open_handle(path, nullptr); + REQUIRE(h != nullptr); + + char* target = nullptr; + REQUIRE(rnt_branch_target(h, &target) == 0); + REQUIRE(take_string(target) == ""); + + REQUIRE(rnt_close_handle(h) == 0); +} + +TEST_CASE("rnt_register_branch rejects an unregistered target hash", + "[step2][capi][branch]") +{ + InitGuard _; + REQUIRE(rnt_register_branch("/system/branches/test_step2_bad_init", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + != 0); +} + +TEST_CASE("rnt_branch_advance rejects an unregistered target hash", + "[step2][capi][branch]") +{ + InitGuard _; + const char* bpath = "/system/branches/test_step2_bad_advance"; + REQUIRE(rnt_register_branch(bpath, "") == 0); + + REQUIRE(rnt_branch_advance( + bpath, + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") != 0); + + // Target unchanged. + rnt_handle_t h = rnt_open_handle(bpath, nullptr); + REQUIRE(h != nullptr); + char* target = nullptr; + REQUIRE(rnt_branch_target(h, &target) == 0); + REQUIRE(take_string(target) == ""); + rnt_close_handle(h); +} + +TEST_CASE("Writing a tuple advances the branch to a new snapshot", + "[step2][step3][capi][branch]") +{ + InitGuard _; + const char* bpath = "/system/branches/test_step2_advance"; + const char* rpath = "/system/branches/test_step2_advance/multigroups/public/relations/items"; + REQUIRE(rnt_register_branch(bpath, "") == 0); + REQUIRE(rnt_register_relation(rpath) == 0); + + // After register_relation, the branch is bound to the first snapshot. + rnt_handle_t h = rnt_open_handle(bpath, nullptr); + REQUIRE(h != nullptr); + char* first = nullptr; + REQUIRE(rnt_branch_target(h, &first) == 0); + const std::string first_hash = take_string(first); + REQUIRE(first_hash.size() == 64); + rnt_close_handle(h); + + // A tuple write advances the branch again. + char* tuple_hash = nullptr; + REQUIRE(rnt_link_tuple(rpath, "id=1\n", &tuple_hash) == 0); + rnt_free_string(tuple_hash); + + h = rnt_open_handle(bpath, nullptr); + REQUIRE(h != nullptr); + char* second = nullptr; + REQUIRE(rnt_branch_target(h, &second) == 0); + const std::string second_hash = take_string(second); + rnt_close_handle(h); + + REQUIRE(second_hash.size() == 64); + REQUIRE(second_hash != first_hash); +} + +// --------------------------------------------------------------------------- +// Step 3+4 — Copy-on-write writes and resolver wiring +// --------------------------------------------------------------------------- + +TEST_CASE("link_tuple commits a new snapshot and relation_root reflects it", + "[step3][capi][cow]") +{ + InitGuard _; + const char* bpath = "/system/branches/test_step3_link"; + const char* rpath = "/system/branches/test_step3_link/multigroups/public/relations/items"; + REQUIRE(rnt_register_branch(bpath, "") == 0); + REQUIRE(rnt_register_relation(rpath) == 0); + + // Empty relation: root is "". + char* root = nullptr; + REQUIRE(rnt_relation_root(rpath, &root) == 0); + REQUIRE(take_string(root) == ""); + + char* tuple_hash = nullptr; + REQUIRE(rnt_link_tuple(rpath, "id=1\nname=alpha\n", &tuple_hash) == 0); + REQUIRE(tuple_hash != nullptr); + rnt_free_string(tuple_hash); + + REQUIRE(rnt_relation_root(rpath, &root) == 0); + const std::string new_root = take_string(root); + REQUIRE(new_root.size() == 64); // a real Merkle root now +} + +TEST_CASE("unlink_tuple and clear_relation walk back through new snapshots", + "[step3][capi][cow]") +{ + InitGuard _; + const char* bpath = "/system/branches/test_step3_unlink"; + const char* rpath = "/system/branches/test_step3_unlink/multigroups/public/relations/items"; + REQUIRE(rnt_register_branch(bpath, "") == 0); + REQUIRE(rnt_register_relation(rpath) == 0); + + char* th = nullptr; + REQUIRE(rnt_link_tuple(rpath, "id=1\n", &th) == 0); + const std::string tuple_hash = take_string(th); + + char* root = nullptr; + REQUIRE(rnt_relation_root(rpath, &root) == 0); + REQUIRE(take_string(root) != ""); + + REQUIRE(rnt_unlink_tuple(rpath, tuple_hash.c_str()) == 0); + REQUIRE(rnt_relation_root(rpath, &root) == 0); + REQUIRE(take_string(root) == ""); + + // After re-link and clear, root is empty again. + REQUIRE(rnt_link_tuple(rpath, "id=2\n", &th) == 0); + rnt_free_string(th); + REQUIRE(rnt_clear_relation(rpath) == 0); + REQUIRE(rnt_relation_root(rpath, &root) == 0); + REQUIRE(take_string(root) == ""); +} + +TEST_CASE("rnt_open_handle resolves branch-relative paths to snapshot relations", + "[step4][capi][resolver]") +{ + InitGuard _; + const char* bpath = "/system/branches/test_step4_resolve"; + const char* rpath = "/system/branches/test_step4_resolve/multigroups/public/relations/items"; + REQUIRE(rnt_register_branch(bpath, "") == 0); + REQUIRE(rnt_register_relation(rpath) == 0); + + char* tuple_hash = nullptr; + REQUIRE(rnt_link_tuple(rpath, "id=1\n", &tuple_hash) == 0); + rnt_free_string(tuple_hash); + + // Opening the branch-relative path resolves to the snapshot-bound entry, + // and a cursor reads the tuple just written. + rnt_handle_t h = rnt_open_handle(rpath, nullptr); + REQUIRE(h != nullptr); + rnt_cursor_t c = rnt_cursor_open(h); + REQUIRE(c != nullptr); + + char* row = nullptr; + REQUIRE(rnt_cursor_next(c, &row) == 1); + REQUIRE(row != nullptr); + rnt_free_string(row); + + // Exhausted on the next call. + REQUIRE(rnt_cursor_next(c, &row) == 0); + REQUIRE(row == nullptr); + + rnt_cursor_close(c); + rnt_close_handle(h); +} + +TEST_CASE("Reading an unborn branch's relation fails cleanly", + "[step4][capi][resolver]") +{ + InitGuard _; + const char* bpath = "/system/branches/test_step4_unborn"; + REQUIRE(rnt_register_branch(bpath, "") == 0); + + // No relation registered: relation_root miss returns nonzero. + char* root = nullptr; + REQUIRE(rnt_relation_root( + "/system/branches/test_step4_unborn/multigroups/public/relations/missing", &root) != 0); +} + +// --------------------------------------------------------------------------- +// Step 5 — Session lifecycle and overrides +// --------------------------------------------------------------------------- + +TEST_CASE("Each rnt_session_open returns a distinct 64-char hex hash", + "[step5][capi][session]") +{ + InitGuard _; + char* a = nullptr; + char* b = nullptr; + REQUIRE(rnt_session_open(nullptr, &a) == 0); + REQUIRE(rnt_session_open(nullptr, &b) == 0); + const std::string ha = take_string(a); + const std::string hb = take_string(b); + REQUIRE(ha.size() == 64); + REQUIRE(hb.size() == 64); + REQUIRE(ha != hb); + + REQUIRE(rnt_session_close(ha.c_str()) == 0); + REQUIRE(rnt_session_close(hb.c_str()) == 0); +} + +TEST_CASE("rnt_session_close on an unknown hash fails", + "[step5][capi][session]") +{ + InitGuard _; + REQUIRE(rnt_session_close( + "0000000000000000000000000000000000000000000000000000000000000000") != 0); +} + +TEST_CASE("Session override redirects branch-relative reads to a different snapshot", + "[step5][capi][session][resolver]") +{ + InitGuard _; + const char* bpath = "/system/branches/test_step5_override"; + const char* rpath = "/system/branches/test_step5_override/multigroups/public/relations/items"; + REQUIRE(rnt_register_branch(bpath, "") == 0); + REQUIRE(rnt_register_relation(rpath) == 0); + + // Snapshot S1: one tuple. + char* th = nullptr; + REQUIRE(rnt_link_tuple(rpath, "id=1\n", &th) == 0); + rnt_free_string(th); + + // Capture S1's hash and pin it via a session override before the next + // commit — without the override the next link_tuple would cascade-collect + // S1 as soon as the branch advances away from it. + rnt_handle_t bh = rnt_open_handle(bpath, nullptr); + REQUIRE(bh != nullptr); + char* target = nullptr; + REQUIRE(rnt_branch_target(bh, &target) == 0); + const std::string s1 = take_string(target); + rnt_close_handle(bh); + + char* sess = nullptr; + REQUIRE(rnt_session_open(nullptr, &sess) == 0); + const std::string sid = take_string(sess); + REQUIRE(rnt_session_set_branch(sid.c_str(), + "test_step5_override", + s1.c_str()) == 0); + + // Advance the branch to S2 — global HEAD moves on; the session override + // keeps S1 resident. + REQUIRE(rnt_link_tuple(rpath, "id=2\n", &th) == 0); + rnt_free_string(th); + + // Session view: still S1 (one tuple). + const std::string spath = + "/system/sessions/" + sid + + "/branches/test_step5_override/multigroups/public/relations/items"; + + rnt_handle_t h_session = rnt_open_handle(spath.c_str(), nullptr); + REQUIRE(h_session != nullptr); + rnt_cursor_t c_session = rnt_cursor_open(h_session); + REQUIRE(c_session != nullptr); + + int rows_under_session = 0; + char* row = nullptr; + while (rnt_cursor_next(c_session, &row) == 1) + { + ++rows_under_session; + rnt_free_string(row); + } + REQUIRE(rows_under_session == 1); + + rnt_cursor_close(c_session); + rnt_close_handle(h_session); + + // Global view sees both tuples. + rnt_handle_t h_global = rnt_open_handle(rpath, nullptr); + REQUIRE(h_global != nullptr); + rnt_cursor_t c_global = rnt_cursor_open(h_global); + REQUIRE(c_global != nullptr); + int rows_global = 0; + while (rnt_cursor_next(c_global, &row) == 1) { ++rows_global; rnt_free_string(row); } + REQUIRE(rows_global == 2); + rnt_cursor_close(c_global); + rnt_close_handle(h_global); + + REQUIRE(rnt_session_close(sid.c_str()) == 0); +} + +TEST_CASE("Clearing a session override falls back to the global branch HEAD", + "[step5][capi][session][resolver]") +{ + InitGuard _; + const char* bpath = "/system/branches/test_step5_clear"; + const char* rpath = "/system/branches/test_step5_clear/multigroups/public/relations/items"; + REQUIRE(rnt_register_branch(bpath, "") == 0); + REQUIRE(rnt_register_relation(rpath) == 0); + + char* th = nullptr; + REQUIRE(rnt_link_tuple(rpath, "id=1\n", &th) == 0); + rnt_free_string(th); + rnt_handle_t bh = rnt_open_handle(bpath, nullptr); + REQUIRE(bh != nullptr); + char* target = nullptr; + REQUIRE(rnt_branch_target(bh, &target) == 0); + const std::string s1 = take_string(target); + rnt_close_handle(bh); + + // Same pattern as above: install the override on S1 before advancing. + char* sess = nullptr; + REQUIRE(rnt_session_open(nullptr, &sess) == 0); + const std::string sid = take_string(sess); + REQUIRE(rnt_session_set_branch(sid.c_str(), "test_step5_clear", s1.c_str()) == 0); + + REQUIRE(rnt_link_tuple(rpath, "id=2\n", &th) == 0); + rnt_free_string(th); + + // Clear the override. + REQUIRE(rnt_session_set_branch(sid.c_str(), "test_step5_clear", "") == 0); + + // Now the session sees the global view (two tuples). + const std::string spath = + "/system/sessions/" + sid + + "/branches/test_step5_clear/multigroups/public/relations/items"; + rnt_handle_t h = rnt_open_handle(spath.c_str(), nullptr); + REQUIRE(h != nullptr); + rnt_cursor_t c = rnt_cursor_open(h); + REQUIRE(c != nullptr); + int rows = 0; + char* row = nullptr; + while (rnt_cursor_next(c, &row) == 1) { ++rows; rnt_free_string(row); } + REQUIRE(rows == 2); + rnt_cursor_close(c); + rnt_close_handle(h); + + REQUIRE(rnt_session_close(sid.c_str()) == 0); +} + +TEST_CASE("rnt_session_set_branch rejects an unregistered snapshot", + "[step5][capi][session]") +{ + InitGuard _; + char* sess = nullptr; + REQUIRE(rnt_session_open(nullptr, &sess) == 0); + const std::string sid = take_string(sess); + + REQUIRE(rnt_session_set_branch( + sid.c_str(), + "anything", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") != 0); + + REQUIRE(rnt_session_close(sid.c_str()) == 0); +} + +// --------------------------------------------------------------------------- +// Multi-mg-per-branch — sibling multigroups under one branch evolve +// independently. Inserting into one mg must not perturb the other's hash +// or its enumerable relation set. +// --------------------------------------------------------------------------- + +TEST_CASE("Two multigroups under one branch advance independently", + "[capi][multi-mg]") +{ + InitGuard _; + const std::string bpath = "/system/branches/test_multi_mg"; + REQUIRE(rnt_register_branch(bpath.c_str(), "") == 0); + + // Register one relation in each of two distinct multigroups. + REQUIRE(rnt_register_relation( + (bpath + "/multigroups/warehouse/relations/orders").c_str()) == 0); + REQUIRE(rnt_register_relation( + (bpath + "/multigroups/audit/relations/events").c_str()) == 0); + + char* mgs_raw = nullptr; + REQUIRE(rnt_list_branch_multigroups(bpath.c_str(), &mgs_raw) == 0); + const std::string mgs = take_string(mgs_raw); + REQUIRE(mgs.find("warehouse\t") != std::string::npos); + REQUIRE(mgs.find("audit\t") != std::string::npos); + + // Insert into warehouse; record audit's root before/after. + char* audit_before = nullptr; + REQUIRE(rnt_list_relations( + (bpath + "/multigroups/audit").c_str(), &audit_before) == 0); + const std::string audit_before_s = take_string(audit_before); + + char* th = nullptr; + REQUIRE(rnt_link_tuple((bpath + "/multigroups/warehouse/relations/orders").c_str(), + "id=1\n", &th) == 0); + rnt_free_string(th); + + char* audit_after = nullptr; + REQUIRE(rnt_list_relations( + (bpath + "/multigroups/audit").c_str(), &audit_after) == 0); + const std::string audit_after_s = take_string(audit_after); + + // audit's relation set is byte-identical: the warehouse-side mutation + // touched only the warehouse subtree at the multigroup level. + REQUIRE(audit_before_s == audit_after_s); + + // warehouse/orders is now non-empty. + char* root = nullptr; + REQUIRE(rnt_relation_root( + (bpath + "/multigroups/warehouse/relations/orders").c_str(), &root) == 0); + REQUIRE(!take_string(root).empty()); + + // audit/events still empty. + char* root2 = nullptr; + REQUIRE(rnt_relation_root( + (bpath + "/multigroups/audit/relations/events").c_str(), &root2) == 0); + REQUIRE(take_string(root2).empty()); +} diff --git a/test/CursorManager.cpp b/test/CursorManager.cpp new file mode 100644 index 0000000..8c7de1a --- /dev/null +++ b/test/CursorManager.cpp @@ -0,0 +1,160 @@ +#include <catch2/catch_test_macros.hpp> + +#include "CursorManager.h" +#include "HandlerManager.h" +#include "IdentityManager.h" +#include "InMemoryBackend.h" +#include "LifecycleManager.h" +#include "Merkle.h" +#include "MultigroupCodec.h" +#include "NamespaceReferenceManager.h" +#include "ObjectManager.h" +#include "PermissionsManager.h" +#include "TupleCodec.h" + +#include <memory> + +// --------------------------------------------------------------------------- +// Step 6 / cursor-independence follow-up +// +// Two things to verify: +// 1. A cursor opened on a /system/snapshots/<H>/relations/<n> relation +// pins the parent Multigroup for its lifetime; releasing the cursor +// releases the pin. +// 2. Closing the handle a cursor was opened from no longer dereferences +// a freed handle pointer inside Next() — the cursor captures the type +// label and the ephemeral generator at Open time, so iteration after +// the handle's death is degenerate but safe. +// --------------------------------------------------------------------------- + +namespace +{ + std::unique_ptr<nt::ObjectManager::object_type> make_type(OBJECT_TYPE label) + { + auto t = std::make_unique<nt::ObjectManager::object_type>(); + t->label = label; + t->methods = { OPEN, CLOSE }; + return t; + } + + std::unique_ptr<nt::ObjectManager::ephemeral_object_type> + make_constant_eph_type(std::vector<nt::Attribute> attrs) + { + auto t = std::make_unique<nt::ObjectManager::ephemeral_object_type>(); + t->label = EPHEMERAL_RELATION; + t->methods = { OPEN, CLOSE }; + t->cardinality = nt::ObjectManager::ephemeral_object_type::Cardinality::Finite; + t->generator = [attrs](const std::vector<std::string>&, + std::size_t offset, std::size_t limit) + -> std::vector<nt::Tuple> + { + if (offset > 0 || limit == 0) return {}; + return { nt::Tuple(attrs) }; + }; + return t; + } + + struct Stack { + nt::InMemoryBackend backend; + nt::ObjectManager objects; + nt::PermissionsManager permissions; + nt::IdentityManager identities; + nt::LifecycleManager lifecycles { objects, backend }; + nt::NamespaceReferenceManager references { objects, backend }; + nt::HandlerManager handler { objects, permissions, identities, + lifecycles, references }; + nt::CursorManager cursors { backend, &lifecycles, &objects }; + }; +} + +TEST_CASE("Cursor pins the parent snapshot and unpins on close", + "[step6][cursor][pin]") +{ + Stack s; + + // Stand up a minimal snapshot with one relation. + auto& om = s.objects; + om.Register({"system", "snapshots", "Hcur"}, + std::make_unique<nt::ObjectManager::Multigroup>(), make_type(MULTIGROUP)); + om.Register({"system", "snapshots", "Hcur", "relations", "items"}, + std::make_unique<nt::ObjectManager::Relation>(), make_type(RELATION)); + + auto* mg_entry = om.Find({"system", "snapshots", "Hcur"}); + auto* rel_entry = om.Find({"system", "snapshots", "Hcur", "relations", "items"}); + + // Parent's pin on the child Relation (mirrors register_snapshot in the C + // API). An external pin on the Multigroup simulates a branch holding the + // snapshot — without it, closing the cursor would drop ref_count to 0 and + // cascade-collect mg_entry mid-test. + s.lifecycles.Pin(rel_entry); + s.lifecycles.Pin(mg_entry); + + // Open a handle and a cursor on the child Relation. + int conn = 1; + auto* h = s.handler.Open({"system", "snapshots", "Hcur", "relations", "items"}, &conn); + REQUIRE(h != nullptr); + REQUIRE(mg_entry->head->reference_count == 1); // external pin only + + auto* c = s.cursors.Open(h); + REQUIRE(c != nullptr); + REQUIRE(c->pinned_snapshot == mg_entry); + REQUIRE(mg_entry->head->reference_count == 2); // external + cursor pin + + s.cursors.Close(c); + REQUIRE(mg_entry->head->reference_count == 1); // back to external only + + s.handler.Close(h); + s.lifecycles.Unpin(mg_entry); // releases the external pin; cascade-collects. +} + +TEST_CASE("Closing the handle mid-iteration does not crash an ephemeral cursor", + "[step6][cursor][handle-independence]") +{ + Stack s; + // Register an ephemeral relation; its generator yields one tuple. + s.objects.Register({"eph"}, + std::make_unique<nt::ObjectManager::Relation>(), + make_constant_eph_type({{"k", "v"}})); + + int conn = 1; + auto* h = s.handler.Open({"eph"}, &conn); + REQUIRE(h != nullptr); + auto* c = s.cursors.Open(h); + REQUIRE(c != nullptr); + + // Ephemeral cursors start exhausted; reset to simulate a JOIN probe. + c->exhausted = false; + c->args = {}; + + // Now violate the documented contract: close the handle BEFORE Next(). + // The cursor must still produce its tuple from the captured generator + // without dereferencing the freed handle. + s.handler.Close(h); + + auto* tuple = s.cursors.Next(c); + REQUIRE(tuple != nullptr); + bool found = false; + for (const auto& a : tuple->attrs()) + if (a.name == "k" && a.value == "v") { found = true; break; } + REQUIRE(found); + + s.cursors.Close(c); +} + +TEST_CASE("Cursor opened against a non-snapshot relation does not pin anything", + "[step6][cursor][pin]") +{ + Stack s; + s.objects.Register({"plain", "rel"}, + std::make_unique<nt::ObjectManager::Relation>(), + make_type(RELATION)); + int conn = 1; + auto* h = s.handler.Open({"plain", "rel"}, &conn); + REQUIRE(h != nullptr); + auto* c = s.cursors.Open(h); + REQUIRE(c != nullptr); + REQUIRE(c->pinned_snapshot == nullptr); + + s.cursors.Close(c); + s.handler.Close(h); +} diff --git a/test/LifecycleManager.cpp b/test/LifecycleManager.cpp new file mode 100644 index 0000000..aa75e14 --- /dev/null +++ b/test/LifecycleManager.cpp @@ -0,0 +1,185 @@ +#include <catch2/catch_test_macros.hpp> + +#include "InMemoryBackend.h" +#include "LifecycleManager.h" +#include "ObjectManager.h" + +#include <memory> + +// --------------------------------------------------------------------------- +// Step 6 — Pin/Unpin and the cascade +// +// LifecycleManager owns object lifetime. Pin/Unpin adjust reference_count; +// Monitor/Unmonitor adjust handle_count. Both decrement paths call into +// TryCollect, which gates GC on the joint condition (both counters zero) +// and, for MULTIGROUP entries, cascades through CascadeMultigroup to +// unpin every Relation registered under their snapshot subtree. +// --------------------------------------------------------------------------- + +namespace +{ + std::unique_ptr<nt::ObjectManager::object_type> make_type(OBJECT_TYPE label) + { + auto t = std::make_unique<nt::ObjectManager::object_type>(); + t->label = label; + t->methods = { OPEN, CLOSE }; + return t; + } +} + +TEST_CASE("Pin and Unpin adjust reference_count symmetrically", + "[step6][lifecycle][pin]") +{ + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + om.Register({"r1"}, std::make_unique<nt::ObjectManager::Relation>(), make_type(RELATION)); + auto* entry = om.Find({"r1"}); + REQUIRE(entry->head->reference_count == 0); + + lm.Pin(entry); + lm.Pin(entry); + REQUIRE(entry->head->reference_count == 2); + + lm.Unpin(entry); + REQUIRE(entry->head->reference_count == 1); + + // Still alive at ref_count=1. + REQUIRE(om.Find({"r1"}) != nullptr); +} + +TEST_CASE("Pin on nullptr is a no-op", "[step6][lifecycle][pin]") +{ + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + REQUIRE_NOTHROW(lm.Pin(nullptr)); + REQUIRE_NOTHROW(lm.Unpin(nullptr)); +} + +TEST_CASE("Unpin below zero does not underflow", "[step6][lifecycle][pin]") +{ + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + om.Register({"r1"}, std::make_unique<nt::ObjectManager::Relation>(), make_type(RELATION)); + auto* entry = om.Find({"r1"}); + + lm.Unpin(entry); + REQUIRE(entry->head->reference_count == 0); +} + +TEST_CASE("GC is gated on both counters reaching zero", "[step6][lifecycle][gc]") +{ + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + om.Register({"r1"}, std::make_unique<nt::ObjectManager::Relation>(), make_type(RELATION)); + auto* entry = om.Find({"r1"}); + + lm.Monitor(entry); + lm.Pin(entry); + + // handle_count=1, ref_count=1. + lm.Unpin(entry); // ref_count → 0, but handle_count still 1. + REQUIRE(om.Find({"r1"}) != nullptr); + + lm.Pin(entry); + lm.Unmonitor(entry); // handle_count → 0, but ref_count still 1. + REQUIRE(om.Find({"r1"}) != nullptr); + + lm.Unpin(entry); // both zero → GC. + REQUIRE(om.Find({"r1"}) == nullptr); +} + +TEST_CASE("Unmonitor that brings both counters to zero collects the entry", + "[step6][lifecycle][gc]") +{ + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + om.Register({"r1"}, std::make_unique<nt::ObjectManager::Relation>(), make_type(RELATION)); + auto* entry = om.Find({"r1"}); + + lm.Monitor(entry); + REQUIRE(entry->head->handle_count == 1); + lm.Unmonitor(entry); + REQUIRE(om.Find({"r1"}) == nullptr); +} + +TEST_CASE("IsEligibleForGC reflects the joint-zero rule", + "[step6][lifecycle][gc]") +{ + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + om.Register({"r1"}, std::make_unique<nt::ObjectManager::Relation>(), make_type(RELATION)); + auto* entry = om.Find({"r1"}); + + REQUIRE(lm.IsEligibleForGC(entry)); + lm.Pin(entry); + REQUIRE_FALSE(lm.IsEligibleForGC(entry)); +} + +TEST_CASE("CascadeMultigroup unpins child Relations on snapshot collection", + "[step6][lifecycle][cascade]") +{ + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + + om.Register({"system", "snapshots", "H1"}, + std::make_unique<nt::ObjectManager::Multigroup>(), make_type(MULTIGROUP)); + auto* mg_entry = om.Find({"system", "snapshots", "H1"}); + + for (const auto& n : { std::string{"foo"}, std::string{"bar"} }) + { + om.Register({"system", "snapshots", "H1", "relations", n}, + std::make_unique<nt::ObjectManager::Relation>(), + make_type(RELATION)); + // The parent Multigroup's pin on each child, mirroring what + // register_snapshot does in the C API layer. + lm.Pin(om.Find({"system", "snapshots", "H1", "relations", n})); + } + REQUIRE(om.Find({"system", "snapshots", "H1", "relations", "foo"}) != nullptr); + REQUIRE(om.Find({"system", "snapshots", "H1", "relations", "bar"}) != nullptr); + + // One external pin on the Multigroup (e.g. a branch). Release it: cascade. + lm.Pin(mg_entry); + lm.Unpin(mg_entry); + + REQUIRE(om.Find({"system", "snapshots", "H1"}) == nullptr); + REQUIRE(om.Find({"system", "snapshots", "H1", "relations", "foo"}) == nullptr); + REQUIRE(om.Find({"system", "snapshots", "H1", "relations", "bar"}) == nullptr); +} + +TEST_CASE("Cascade survives a child with an open handle", + "[step6][lifecycle][cascade]") +{ + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + + om.Register({"system", "snapshots", "H2"}, + std::make_unique<nt::ObjectManager::Multigroup>(), make_type(MULTIGROUP)); + auto* mg_entry = om.Find({"system", "snapshots", "H2"}); + + om.Register({"system", "snapshots", "H2", "relations", "live"}, + std::make_unique<nt::ObjectManager::Relation>(), + make_type(RELATION)); + auto* child = om.Find({"system", "snapshots", "H2", "relations", "live"}); + lm.Pin(child); // parent's pin + lm.Monitor(child); // simulate an open handle on the child + + // Drop the Multigroup's external ref: cascade unpins child (ref → 0), + // but handle_count is still 1 — child must survive. + lm.Pin(mg_entry); + lm.Unpin(mg_entry); + + REQUIRE(om.Find({"system", "snapshots", "H2"}) == nullptr); + REQUIRE(om.Find({"system", "snapshots", "H2", "relations", "live"}) != nullptr); + + // Closing the handle now releases the child. + lm.Unmonitor(child); + REQUIRE(om.Find({"system", "snapshots", "H2", "relations", "live"}) == nullptr); +} diff --git a/test/MultigroupCodec.cpp b/test/MultigroupCodec.cpp new file mode 100644 index 0000000..25c8b53 --- /dev/null +++ b/test/MultigroupCodec.cpp @@ -0,0 +1,145 @@ +#include <catch2/catch_test_macros.hpp> + +#include "InMemoryBackend.h" +#include "MultigroupCodec.h" + +#include <string> +#include <vector> + +// --------------------------------------------------------------------------- +// Multigroup snapshot — Merkle<string> tree mapping relation name → relation root. +// +// The codec is a thin facade over Merkle<std::string>; these tests confirm +// the tree is order-independent (it sorts intrinsically), that mutations are +// path-localised (sibling entries are untouched), and that snapshots can be +// re-enumerated from a cold backend by hash alone. +// --------------------------------------------------------------------------- + +namespace { + +// Pad a short label into a deterministic 64-char hex string so the codec can +// decode it as a 32-byte payload. +std::string mk_root(char c, char d = '0') +{ + std::string h(64, '0'); + h[0] = c; + h[1] = d; + return h; +} + +} // namespace + +TEST_CASE("Build is order-independent — tree sorts intrinsically", + "[multigroup-codec]") +{ + nt::InMemoryBackend a, b; + + std::vector<nt::MultigroupCodec::RelationEntry> in_order = { + {"alpha", mk_root('a')}, + {"bravo", mk_root('b')}, + {"charlie", mk_root('c')}, + }; + std::vector<nt::MultigroupCodec::RelationEntry> shuffled = { + {"charlie", mk_root('c')}, + {"alpha", mk_root('a')}, + {"bravo", mk_root('b')}, + }; + + REQUIRE(nt::MultigroupCodec::Build(a, in_order) + == nt::MultigroupCodec::Build(b, shuffled)); +} + +TEST_CASE("Build distinguishes content changes", "[multigroup-codec]") +{ + nt::InMemoryBackend backend; + + std::vector<nt::MultigroupCodec::RelationEntry> base = {{"foo", mk_root('a')}}; + std::vector<nt::MultigroupCodec::RelationEntry> different_root = {{"foo", mk_root('b')}}; + std::vector<nt::MultigroupCodec::RelationEntry> different_name = {{"bar", mk_root('a')}}; + std::vector<nt::MultigroupCodec::RelationEntry> extra_member = { + {"foo", mk_root('a')}, {"bar", mk_root('b')} + }; + + const auto h_base = nt::MultigroupCodec::Build(backend, base); + REQUIRE(h_base != nt::MultigroupCodec::Build(backend, different_root)); + REQUIRE(h_base != nt::MultigroupCodec::Build(backend, different_name)); + REQUIRE(h_base != nt::MultigroupCodec::Build(backend, extra_member)); +} + +TEST_CASE("Build then List round-trips entries in key-sorted order", + "[multigroup-codec]") +{ + nt::InMemoryBackend backend; + + std::vector<nt::MultigroupCodec::RelationEntry> entries = { + {"foo", mk_root('a', '1')}, + {"bar", mk_root('b', '2')}, + {"baz", mk_root('c', '3')}, + }; + const auto root = nt::MultigroupCodec::Build(backend, entries); + + auto listed = nt::MultigroupCodec::List(backend, root); + REQUIRE(listed.size() == 3); + REQUIRE(listed[0].first == "bar"); + REQUIRE(listed[1].first == "baz"); + REQUIRE(listed[2].first == "foo"); + REQUIRE(listed[0].second == mk_root('b', '2')); +} + +TEST_CASE("Lookup returns payload by name", "[multigroup-codec]") +{ + nt::InMemoryBackend backend; + + std::vector<nt::MultigroupCodec::RelationEntry> entries = { + {"foo", mk_root('a')}, + {"bar", mk_root('b')}, + }; + const auto root = nt::MultigroupCodec::Build(backend, entries); + + REQUIRE(nt::MultigroupCodec::Lookup(backend, root, "foo") == mk_root('a')); + REQUIRE(nt::MultigroupCodec::Lookup(backend, root, "bar") == mk_root('b')); + REQUIRE(nt::MultigroupCodec::Lookup(backend, root, "missing").empty()); +} + +TEST_CASE("InsertOne mutates one entry; sibling subtree is untouched", + "[multigroup-codec]") +{ + nt::InMemoryBackend backend; + + std::vector<nt::MultigroupCodec::RelationEntry> entries = { + {"foo", mk_root('a')}, + {"bar", mk_root('b')}, + }; + const auto root0 = nt::MultigroupCodec::Build(backend, entries); + + // Mutate foo only; bar's payload must round-trip unchanged. + const auto root1 = nt::MultigroupCodec::InsertOne( + backend, root0, "foo", mk_root('d')); + REQUIRE(root1 != root0); + REQUIRE(nt::MultigroupCodec::Lookup(backend, root1, "foo") == mk_root('d')); + REQUIRE(nt::MultigroupCodec::Lookup(backend, root1, "bar") == mk_root('b')); +} + +TEST_CASE("RemoveOne deletes one entry; sibling preserved", + "[multigroup-codec]") +{ + nt::InMemoryBackend backend; + + std::vector<nt::MultigroupCodec::RelationEntry> entries = { + {"foo", mk_root('a')}, + {"bar", mk_root('b')}, + }; + const auto root0 = nt::MultigroupCodec::Build(backend, entries); + + const auto root1 = nt::MultigroupCodec::RemoveOne(backend, root0, "foo"); + REQUIRE(nt::MultigroupCodec::Lookup(backend, root1, "foo").empty()); + REQUIRE(nt::MultigroupCodec::Lookup(backend, root1, "bar") == mk_root('b')); +} + +TEST_CASE("Empty multigroup builds to an empty root", "[multigroup-codec]") +{ + nt::InMemoryBackend backend; + std::vector<nt::MultigroupCodec::RelationEntry> empty; + REQUIRE(nt::MultigroupCodec::Build(backend, empty).empty()); + REQUIRE(nt::MultigroupCodec::List(backend, "").empty()); +} diff --git a/test/NamespaceReferenceManager.cpp b/test/NamespaceReferenceManager.cpp new file mode 100644 index 0000000..27f0dcd --- /dev/null +++ b/test/NamespaceReferenceManager.cpp @@ -0,0 +1,203 @@ +#include <catch2/catch_test_macros.hpp> + +#include "InMemoryBackend.h" +#include "Merkle.h" +#include "NamespaceReferenceManager.h" +#include "ObjectManager.h" + +#include <memory> + +// --------------------------------------------------------------------------- +// Resolver — two-level reparse through the branch tree. +// +// NamespaceReferenceManager::Resolve rewrites +// /system/branches/<name>/multigroups/<mg>/<sub>... +// into +// /system/snapshots/<mg_hash>/<sub>... +// by reading branch.target_hash (a Merkle<string> root) from storage and +// looking up <mg>. Session paths consult the per-session override map first +// and fall back to the global branch when no override is set. +// --------------------------------------------------------------------------- + +namespace { + +using Path = std::vector<std::string>; + +// Pad a label into a deterministic 64-char hex string so the merkle codec can +// decode it as a 32-byte payload. +std::string mk_hash(char c, char d = '0') +{ + std::string h(64, '0'); + h[0] = c; + h[1] = d; + return h; +} + +std::unique_ptr<nt::ObjectManager::object_type> make_type(OBJECT_TYPE label) +{ + auto t = std::make_unique<nt::ObjectManager::object_type>(); + t->label = label; + t->methods = { OPEN, CLOSE }; + return t; +} + +void register_branch(nt::ObjectManager& om, + const std::string& name, + const std::string& target_hash) +{ + auto br = std::make_unique<nt::ObjectManager::Branch>(); + br->name = name; + br->target_hash = target_hash; + om.Register({"system", "branches", name}, std::move(br), make_type(BRANCH)); +} + +void register_session(nt::ObjectManager& om, + const std::string& id, + std::map<std::string, std::string> overrides = {}) +{ + auto s = std::make_unique<nt::ObjectManager::Session>(); + s->branch_overrides = std::move(overrides); + om.Register({"system", "sessions", id}, std::move(s), make_type(SESSION)); +} + +// Builds a branch tree with a single (mg_name → mg_hash) entry. Returns the +// branch-tree root hex. +std::string build_branch_tree(nt::InMemoryBackend& store, + const std::string& mg_name, + const std::string& mg_hash_hex) +{ + return nt::Merkle<std::string>::Insert(store, "", mg_name, + nt::hex_to_bin(mg_hash_hex)); +} + +} // namespace + +TEST_CASE("Branch sub-path rewrites through the branch tree to the mg snapshot", + "[resolve]") +{ + nt::ObjectManager om; + nt::InMemoryBackend store; + + const std::string mg_hash = mk_hash('a'); + const std::string tree = build_branch_tree(store, "warehouse", mg_hash); + register_branch(om, "main", tree); + + nt::NamespaceReferenceManager nrm(om, store); + REQUIRE(nrm.Resolve({"system", "branches", "main", "multigroups", "warehouse", "relations", "items"}) + == Path{"system", "snapshots", mg_hash, "relations", "items"}); +} + +TEST_CASE("Resolve on the branch object itself is left unchanged", "[resolve]") +{ + nt::ObjectManager om; + nt::InMemoryBackend store; + const std::string tree = build_branch_tree(store, "warehouse", mk_hash('a')); + register_branch(om, "main", tree); + + nt::NamespaceReferenceManager nrm(om, store); + const Path p{"system", "branches", "main"}; + REQUIRE(nrm.Resolve(p) == p); +} + +TEST_CASE("Resolve on an unborn branch leaves the path unchanged", "[resolve]") +{ + nt::ObjectManager om; + nt::InMemoryBackend store; + register_branch(om, "main", ""); // unborn + + nt::NamespaceReferenceManager nrm(om, store); + const Path p{"system", "branches", "main", "multigroups", "warehouse", "relations", "items"}; + REQUIRE(nrm.Resolve(p) == p); +} + +TEST_CASE("Resolve on an absent multigroup leaves the path unchanged", "[resolve]") +{ + nt::ObjectManager om; + nt::InMemoryBackend store; + const std::string tree = build_branch_tree(store, "warehouse", mk_hash('a')); + register_branch(om, "main", tree); + + nt::NamespaceReferenceManager nrm(om, store); + const Path p{"system", "branches", "main", "multigroups", "audit", "relations", "events"}; + REQUIRE(nrm.Resolve(p) == p); +} + +TEST_CASE("Resolve on an unknown branch leaves the path unchanged", "[resolve]") +{ + nt::ObjectManager om; + nt::InMemoryBackend store; + nt::NamespaceReferenceManager nrm(om, store); + const Path p{"system", "branches", "ghost", "multigroups", "warehouse", "relations", "items"}; + REQUIRE(nrm.Resolve(p) == p); +} + +TEST_CASE("Non-branch paths pass through unchanged", "[resolve]") +{ + nt::ObjectManager om; + nt::InMemoryBackend store; + nt::NamespaceReferenceManager nrm(om, store); + const Path p{"relations", "anywhere"}; + REQUIRE(nrm.Resolve(p) == p); +} + +TEST_CASE("Session override takes precedence over the global branch HEAD", + "[resolve]") +{ + nt::ObjectManager om; + nt::InMemoryBackend store; + + const std::string mg_global = mk_hash('a'); + const std::string mg_session = mk_hash('b'); + const std::string tree_global = + build_branch_tree(store, "warehouse", mg_global); + const std::string tree_session = + build_branch_tree(store, "warehouse", mg_session); + + register_branch(om, "main", tree_global); + register_session(om, "sess123", {{"main", tree_session}}); + + nt::NamespaceReferenceManager nrm(om, store); + REQUIRE(nrm.Resolve({"system", "sessions", "sess123", "branches", "main", + "multigroups", "warehouse", "relations", "items"}) + == Path{"system", "snapshots", mg_session, "relations", "items"}); +} + +TEST_CASE("Session resolution falls back to global branch when override is absent", + "[resolve]") +{ + nt::ObjectManager om; + nt::InMemoryBackend store; + + const std::string mg_global = mk_hash('a'); + const std::string tree_global = + build_branch_tree(store, "warehouse", mg_global); + + register_branch(om, "main", tree_global); + register_session(om, "sess123"); // no overrides + + nt::NamespaceReferenceManager nrm(om, store); + REQUIRE(nrm.Resolve({"system", "sessions", "sess123", "branches", "main", + "multigroups", "warehouse", "relations", "items"}) + == Path{"system", "snapshots", mg_global, "relations", "items"}); +} + +TEST_CASE("Session resolution with neither override nor global branch is unchanged", + "[resolve]") +{ + nt::ObjectManager om; + nt::InMemoryBackend store; + register_session(om, "sess123"); + + nt::NamespaceReferenceManager nrm(om, store); + const Path p{"system", "sessions", "sess123", "branches", "main", + "multigroups", "warehouse", "relations", "items"}; + REQUIRE(nrm.Resolve(p) == p); +} + +TEST_CASE("Empty input path is returned unchanged", "[resolve]") +{ + nt::ObjectManager om; + nt::InMemoryBackend store; + nt::NamespaceReferenceManager nrm(om, store); + REQUIRE(nrm.Resolve({}).empty()); +} diff --git a/test/ObjectManager.cpp b/test/ObjectManager.cpp new file mode 100644 index 0000000..e97bcb2 --- /dev/null +++ b/test/ObjectManager.cpp @@ -0,0 +1,75 @@ +#include <catch2/catch_test_macros.hpp> + +#include "ObjectManager.h" + +#include <memory> + +// --------------------------------------------------------------------------- +// Step 5 — Unregister +// +// Sessions are disposable and must be removable from the registry on close. +// Unregister is the splice-out primitive that rnt_session_close, the +// LifecycleManager TryCollect path, and any future explicit-delete API all +// share. +// --------------------------------------------------------------------------- + +namespace +{ + std::unique_ptr<nt::ObjectManager::object_type> session_type() + { + auto t = std::make_unique<nt::ObjectManager::object_type>(); + t->label = SESSION; + t->disposable = true; + t->methods = { OPEN, CLOSE }; + return t; + } +} + +TEST_CASE("Unregister removes the entry and Find returns nullptr", "[step5][object-manager]") +{ + nt::ObjectManager om; + om.Register({"system", "sessions", "alpha"}, + std::make_unique<nt::ObjectManager::Session>(), + session_type()); + REQUIRE(om.Find({"system", "sessions", "alpha"}) != nullptr); + + REQUIRE(om.Unregister({"system", "sessions", "alpha"}) == true); + REQUIRE(om.Find({"system", "sessions", "alpha"}) == nullptr); +} + +TEST_CASE("Unregister returns false for missing paths", "[step5][object-manager]") +{ + nt::ObjectManager om; + REQUIRE(om.Unregister({"never", "registered"}) == false); +} + +TEST_CASE("Unregister splices a middle entry without disturbing neighbours", + "[step5][object-manager]") +{ + nt::ObjectManager om; + om.Register({"a"}, std::make_unique<nt::ObjectManager::Session>(), session_type()); + om.Register({"b"}, std::make_unique<nt::ObjectManager::Session>(), session_type()); + om.Register({"c"}, std::make_unique<nt::ObjectManager::Session>(), session_type()); + + REQUIRE(om.Unregister({"b"}) == true); + + REQUIRE(om.Find({"a"}) != nullptr); + REQUIRE(om.Find({"b"}) == nullptr); + REQUIRE(om.Find({"c"}) != nullptr); +} + +TEST_CASE("Unregister at the head of the list works", "[step5][object-manager]") +{ + nt::ObjectManager om; + om.Register({"first"}, std::make_unique<nt::ObjectManager::Session>(), session_type()); + om.Register({"second"}, std::make_unique<nt::ObjectManager::Session>(), session_type()); + + // The most recently registered entry is the list head (LIFO). + REQUIRE(om.Unregister({"second"}) == true); + REQUIRE(om.Find({"second"}) == nullptr); + REQUIRE(om.Find({"first"}) != nullptr); + + // And the new head is removable too. + REQUIRE(om.Unregister({"first"}) == true); + REQUIRE(om.Find({"first"}) == nullptr); +} diff --git a/test/VM.cpp b/test/VM.cpp index 00361cb..b0a602f 100644 --- a/test/VM.cpp +++ b/test/VM.cpp @@ -6,6 +6,7 @@ #include "InMemoryBackend.h" #include "LifecycleManager.h" #include "Merkle.h" +#include "NamespaceReferenceManager.h" #include "ObjectManager.h" #include "PermissionsManager.h" #include "TupleCodec.h" @@ -32,7 +33,9 @@ static void store_tuple(nt::InMemoryBackend& backend, if (!entry) return; auto* rel = dynamic_cast<nt::ObjectManager::Relation*>(entry->object.get()); if (!rel) return; - rel->merkle_root = nt::Merkle::Insert(backend, rel->merkle_root, hash); + auto hash_bin = nt::hex_to_bin(hash); + rel->merkle_root = nt::Merkle<nt::Hash32>::Insert(backend, rel->merkle_root, + hash_bin, hash_bin); } static std::unique_ptr<nt::ObjectManager::object_type> make_relation_type() @@ -74,9 +77,10 @@ struct Fixture nt::ObjectManager objects; nt::PermissionsManager permissions; nt::IdentityManager identities; - nt::LifecycleManager lifecycles; - nt::HandlerManager handler { objects, permissions, identities, lifecycles }; - nt::CursorManager cursors { backend }; + nt::LifecycleManager lifecycles { objects, backend }; + nt::NamespaceReferenceManager references { objects, backend }; + nt::HandlerManager handler { objects, permissions, identities, lifecycles, references }; + nt::CursorManager cursors { backend, &lifecycles, &objects }; int conn = 1; }; |
