persistence: SubstrateStore enable_persistence(dir)

Same pattern as carriers: per-substrate binary record (.sub) with
atomic .tmp+rename, replay on enable, delete on remove. Records
current state across all three E90 axes (location / processing /
ID-status), plus substid / carrierid / slot / free-form location
label. History is deliberately NOT journaled — it's an in-memory
ring buffer and rebuilding from replayed state would mislead.

Five new tests cover full-axis replay, every terminal processing
state, remove-deletes-journal, corrupt-record drop, and the
history-is-transient invariant.

Closes #2 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:31:54 +02:00
parent f56639ba17
commit 1548b49afd
4 changed files with 372 additions and 19 deletions
+186
View File
@@ -0,0 +1,186 @@
// Persistence tests for SubstrateStore. Mirrors test_carrier_persistence:
// enable a journal directory, drive every FSM axis, "restart" by
// constructing a fresh store pointed at the same directory, assert
// replay reproduces in-memory state.
#include <doctest/doctest.h>
#include <filesystem>
#include <fstream>
#include <random>
#include <string>
#include "secsgem/gem/store/substrates.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-substrate-") + 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("SubstrateStore persistence: replay reproduces all three axes") {
auto dir = scratch_dir("replay");
{
SubstrateStore s;
s.enable_persistence(dir);
REQUIRE(s.create("W-1", "CAR-A", 1) ==
SubstrateStore::CreateResult::Created);
REQUIRE(s.create("W-2", "CAR-A", 2) ==
SubstrateStore::CreateResult::Created);
// Drive each axis for W-1: location, processing, id.
REQUIRE(s.fire_location_event("W-1", SubstrateEvent::Acquire, "MOD-A"));
REQUIRE(s.fire_processing_event("W-1",
SubstrateProcessingEvent::StartProcessing));
// The store has no fire_id_event helper, but mutating the FSM does
// trigger handlers; persistence is only refreshed via the store
// entrypoints — for this test we use the FSM directly and then call
// a location event to force a journal rewrite.
REQUIRE(s.get("W-1")->fsm->on_id_event(SubstrateIDEvent::Read));
REQUIRE(s.get("W-1")->fsm->on_id_event(SubstrateIDEvent::Confirm));
REQUIRE(s.fire_location_event("W-1", SubstrateEvent::Release, "DEST"));
REQUIRE(s.fire_processing_event("W-1",
SubstrateProcessingEvent::EndProcessing));
CHECK(count_with_ext(dir, ".sub") == 2);
}
{
SubstrateStore s;
s.enable_persistence(dir);
REQUIRE(s.has("W-1"));
REQUIRE(s.has("W-2"));
const auto* w1 = s.get("W-1");
CHECK(w1->carrierid == "CAR-A");
CHECK(w1->slot == 1);
CHECK(w1->location == "DEST");
CHECK(w1->fsm->location_state() == SubstrateState::AtDestination);
CHECK(w1->fsm->processing_state() == SubstrateProcessingState::Processed);
CHECK(w1->fsm->id_state() == SubstrateIDStatus::Confirmed);
const auto* w2 = s.get("W-2");
CHECK(w2->fsm->location_state() == SubstrateState::AtSource);
CHECK(w2->fsm->processing_state() == SubstrateProcessingState::NeedsProcessing);
}
fs::remove_all(dir);
}
TEST_CASE("SubstrateStore persistence: each terminal processing state round-trips") {
auto dir = scratch_dir("terminal");
{
SubstrateStore s;
s.enable_persistence(dir);
// Abort/Stop/Reject are only legal from InProcess per E90 §6.4.4,
// so drive Start first for those three.
auto drive_into = [&](const std::string& id, uint8_t slot,
SubstrateProcessingEvent terminal) {
REQUIRE(s.create(id, "CAR", slot) ==
SubstrateStore::CreateResult::Created);
REQUIRE(s.fire_processing_event(id,
SubstrateProcessingEvent::StartProcessing));
REQUIRE(s.fire_processing_event(id, terminal));
};
drive_into("W-AB", 1, SubstrateProcessingEvent::Abort);
drive_into("W-ST", 2, SubstrateProcessingEvent::Stop);
drive_into("W-RJ", 3, SubstrateProcessingEvent::Reject);
// Lost and Skip are reachable from NeedsProcessing.
REQUIRE(s.create("W-LO", "CAR", 4) == SubstrateStore::CreateResult::Created);
REQUIRE(s.fire_processing_event("W-LO", SubstrateProcessingEvent::ReportLost));
REQUIRE(s.create("W-SK", "CAR", 5) == SubstrateStore::CreateResult::Created);
REQUIRE(s.fire_processing_event("W-SK", SubstrateProcessingEvent::Skip));
}
{
SubstrateStore s;
s.enable_persistence(dir);
CHECK(s.get("W-AB")->fsm->processing_state() ==
SubstrateProcessingState::Aborted);
CHECK(s.get("W-ST")->fsm->processing_state() ==
SubstrateProcessingState::Stopped);
CHECK(s.get("W-RJ")->fsm->processing_state() ==
SubstrateProcessingState::Rejected);
CHECK(s.get("W-LO")->fsm->processing_state() ==
SubstrateProcessingState::Lost);
CHECK(s.get("W-SK")->fsm->processing_state() ==
SubstrateProcessingState::Skipped);
}
fs::remove_all(dir);
}
TEST_CASE("SubstrateStore persistence: remove deletes journal file") {
auto dir = scratch_dir("remove");
SubstrateStore s;
s.enable_persistence(dir);
REQUIRE(s.create("W-A") == SubstrateStore::CreateResult::Created);
REQUIRE(s.create("W-B") == SubstrateStore::CreateResult::Created);
CHECK(count_with_ext(dir, ".sub") == 2);
REQUIRE(s.remove("W-A"));
CHECK(count_with_ext(dir, ".sub") == 1);
SubstrateStore s2;
s2.enable_persistence(dir);
CHECK_FALSE(s2.has("W-A"));
CHECK(s2.has("W-B"));
fs::remove_all(dir);
}
TEST_CASE("SubstrateStore persistence: corrupt record dropped, others survive") {
auto dir = scratch_dir("corrupt");
{
SubstrateStore s;
s.enable_persistence(dir);
REQUIRE(s.create("W-OK") == SubstrateStore::CreateResult::Created);
}
// Wrong magic.
{
std::ofstream f(dir / "9999999999.sub", std::ios::binary);
const char garbage[] = {0x00, 0x00, 0x00};
f.write(garbage, sizeof(garbage));
}
SubstrateStore s;
s.enable_persistence(dir);
CHECK(s.has("W-OK"));
CHECK(s.size() == 1);
CHECK(count_with_ext(dir, ".sub") == 1);
fs::remove_all(dir);
}
TEST_CASE("SubstrateStore: history is NOT journaled (transient by design)") {
auto dir = scratch_dir("history");
{
SubstrateStore s;
s.enable_persistence(dir);
REQUIRE(s.create("W-H") == SubstrateStore::CreateResult::Created);
REQUIRE(s.fire_location_event("W-H", SubstrateEvent::Acquire, "MOD"));
REQUIRE(s.fire_processing_event("W-H",
SubstrateProcessingEvent::StartProcessing));
CHECK(s.history("W-H")->size() == 2);
}
{
SubstrateStore s;
s.enable_persistence(dir);
REQUIRE(s.has("W-H"));
// State survived but history is empty post-replay — current state
// is what's durable, not the journey to it.
CHECK(s.history("W-H")->size() == 0);
CHECK(s.get("W-H")->fsm->processing_state() ==
SubstrateProcessingState::InProcess);
}
fs::remove_all(dir);
}