Files
secs-gem/tests/test_carrier_persistence.cpp
T
raphael f56639ba17 persistence: CarrierStore + LoadPortStore enable_persistence(dir)
Mirrors SpoolStore: per-record file with atomic .tmp+rename, magic+
version-prefixed binary layout, replay on enable, delete on remove.
FSMs gain a restore_state() that bypasses the transition table and
handlers since a replay isn't a transition.

Six new tests cover write+restart+replay across every CIDS/CSMS/CAS
axis, remove-deletes-journal, malformed-record drop-not-poison, and
the persistence-disabled no-op path.

Closes #1 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 10:25:50 +02:00

210 lines
6.5 KiB
C++

// Persistence tests for CarrierStore + LoadPortStore.
//
// Mirrors the pattern from test_data_model.cpp:spool-persistence: enable
// a journal directory, mutate state, "restart" by constructing a fresh
// store pointed at the same directory, and assert replay reproduces the
// in-memory state. Plus negative paths: partial-write files are
// dropped, remove deletes the file, persistence-disabled is a no-op.
#include <doctest/doctest.h>
#include <filesystem>
#include <fstream>
#include <random>
#include <string>
#include "secsgem/gem/store/carriers.hpp"
using namespace secsgem::gem;
namespace fs = std::filesystem;
namespace {
fs::path scratch_dir(const char* tag) {
std::random_device rd;
auto p = fs::temp_directory_path() /
(std::string("secsgem-carrier-") + tag + "-" +
std::to_string(rd()));
fs::remove_all(p);
fs::create_directories(p);
return p;
}
std::size_t count_with_ext(const fs::path& dir, const std::string& ext) {
std::size_t n = 0;
std::error_code ec;
for (auto& e : fs::directory_iterator(dir, ec)) {
if (e.is_regular_file() && e.path().extension() == ext) ++n;
}
return n;
}
} // namespace
TEST_CASE("CarrierStore persistence: write + restart + replay") {
auto dir = scratch_dir("write-replay");
{
CarrierStore s;
s.enable_persistence(dir);
REQUIRE(s.create("CAR-1", /*port=*/2, /*capacity=*/4) ==
CarrierStore::CreateResult::Created);
REQUIRE(s.create("CAR-2", /*port=*/3, /*capacity=*/4) ==
CarrierStore::CreateResult::Created);
// Drive each FSM axis through a transition so we can prove every
// byte makes it through the journal.
REQUIRE(s.fire_id_event("CAR-1", CarrierIDEvent::ProceedWithCarrier));
REQUIRE(s.fire_slot_map_event("CAR-1", SlotMapEvent::Read));
REQUIRE(s.fire_access_event("CAR-1", CarrierAccessEvent::BeginAccess));
s.set_slot_state("CAR-1", 0, 1); // slot 1 occupied
s.set_slot_state("CAR-1", 1, 0); // slot 2 empty
CHECK(count_with_ext(dir, ".car") == 2);
}
// Restart: fresh store, same dir.
{
CarrierStore s;
s.enable_persistence(dir);
REQUIRE(s.has("CAR-1"));
REQUIRE(s.has("CAR-2"));
const auto* c1 = s.get("CAR-1");
REQUIRE(c1);
CHECK(c1->port_id == 2);
CHECK(c1->slots.size() == 4);
CHECK(c1->slots[0].state == 1);
CHECK(c1->slots[1].state == 0);
CHECK(c1->fsm->id_status() == CarrierIDStatus::Confirmed);
CHECK(c1->fsm->slot_map_status() == SlotMapStatus::Read);
CHECK(c1->fsm->access_status() == CarrierAccessStatus::InAccess);
const auto* c2 = s.get("CAR-2");
REQUIRE(c2);
CHECK(c2->port_id == 3);
CHECK(c2->fsm->id_status() == CarrierIDStatus::NotConfirmed);
// Continued mutation after replay still writes through.
REQUIRE(s.fire_id_event("CAR-2", CarrierIDEvent::Bind));
}
// Second restart shows the post-replay mutation is durable.
{
CarrierStore s;
s.enable_persistence(dir);
REQUIRE(s.has("CAR-2"));
CHECK(s.get("CAR-2")->fsm->id_status() == CarrierIDStatus::Confirmed);
}
fs::remove_all(dir);
}
TEST_CASE("CarrierStore persistence: remove deletes the journal file") {
auto dir = scratch_dir("remove");
CarrierStore s;
s.enable_persistence(dir);
REQUIRE(s.create("CAR-A") == CarrierStore::CreateResult::Created);
REQUIRE(s.create("CAR-B") == CarrierStore::CreateResult::Created);
CHECK(count_with_ext(dir, ".car") == 2);
REQUIRE(s.remove("CAR-A"));
CHECK(count_with_ext(dir, ".car") == 1);
// Restart and confirm CAR-A is gone, CAR-B remains.
CarrierStore s2;
s2.enable_persistence(dir);
CHECK_FALSE(s2.has("CAR-A"));
CHECK(s2.has("CAR-B"));
fs::remove_all(dir);
}
TEST_CASE("CarrierStore persistence: malformed file is dropped, not poisonous") {
auto dir = scratch_dir("malformed");
{
CarrierStore s;
s.enable_persistence(dir);
REQUIRE(s.create("CAR-OK") == CarrierStore::CreateResult::Created);
}
// Inject a corrupt record (wrong magic byte at offset 0).
{
std::ofstream f(dir / "9999999999.car", std::ios::binary);
const char garbage[] = {0x00, 0x01, 0x02, 0x03};
f.write(garbage, sizeof(garbage));
}
// And a truncated record (magic+version only).
{
std::ofstream f(dir / "9999999998.car", std::ios::binary);
const char hdr[] = {static_cast<char>(0xC4), 0x01};
f.write(hdr, sizeof(hdr));
}
CHECK(count_with_ext(dir, ".car") == 3);
CarrierStore s;
s.enable_persistence(dir);
CHECK(s.has("CAR-OK"));
CHECK(s.size() == 1);
// The two malformed files were removed during replay.
CHECK(count_with_ext(dir, ".car") == 1);
fs::remove_all(dir);
}
TEST_CASE("CarrierStore: persistence disabled is a no-op") {
auto dir = scratch_dir("disabled");
CarrierStore s;
// Note: enable_persistence NOT called.
REQUIRE(s.create("CAR-X") == CarrierStore::CreateResult::Created);
REQUIRE(s.fire_id_event("CAR-X", CarrierIDEvent::ProceedWithCarrier));
CHECK_FALSE(s.persistence_enabled());
// No journal file should exist anywhere — but to be safe also check
// that pointing a fresh store at `dir` doesn't see CAR-X (since we
// never wrote it).
CarrierStore s2;
s2.enable_persistence(dir);
CHECK_FALSE(s2.has("CAR-X"));
CHECK(s2.size() == 0);
fs::remove_all(dir);
}
TEST_CASE("LoadPortStore persistence: write + restart + replay") {
auto dir = scratch_dir("lp-write-replay");
{
LoadPortStore lp;
lp.enable_persistence(dir);
REQUIRE(lp.create(1));
REQUIRE(lp.create(2));
REQUIRE(lp.associate(1, "CAR-1"));
REQUIRE(lp.fire_transfer_event(1, LoadPortTransferEvent::StartLoading));
REQUIRE(lp.fire_reservation_event(2, LoadPortReservationEvent::Reserve));
CHECK(count_with_ext(dir, ".lp") == 2);
}
{
LoadPortStore lp;
lp.enable_persistence(dir);
REQUIRE(lp.has(1));
REQUIRE(lp.has(2));
const auto* p1 = lp.get(1);
CHECK(p1->carrierid == "CAR-1");
CHECK(p1->fsm->transfer_state() == LoadPortTransferState::Loading);
CHECK(p1->fsm->association_state() == LoadPortAssociationStatus::Associated);
const auto* p2 = lp.get(2);
CHECK(p2->fsm->reservation_state() == LoadPortReservationStatus::Reserved);
}
fs::remove_all(dir);
}
TEST_CASE("LoadPortStore persistence: malformed file dropped") {
auto dir = scratch_dir("lp-malformed");
{
std::ofstream f(dir / "001.lp", std::ios::binary);
const char junk[] = {0x00, 0x01, 0x02};
f.write(junk, sizeof(junk));
}
LoadPortStore lp;
lp.enable_persistence(dir);
CHECK(lp.size() == 0);
CHECK(count_with_ext(dir, ".lp") == 0);
fs::remove_all(dir);
}