// 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 #include #include #include #include #include #include #include #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: v2 history survives restart with axis + event preserved") { 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)); REQUIRE(s.fire_processing_event("W-H", SubstrateProcessingEvent::EndProcessing)); CHECK(s.history("W-H")->size() == 3); } { SubstrateStore s; s.enable_persistence(dir); REQUIRE(s.has("W-H")); const auto* hist = s.history("W-H"); REQUIRE(hist); REQUIRE(hist->size() == 3); // Entry 0: location-axis Acquire, processing-axis NoState. CHECK((*hist)[0].location_to == SubstrateState::AtWork); CHECK((*hist)[0].processing_to == SubstrateProcessingState::NoState); CHECK(std::holds_alternative((*hist)[0].event)); CHECK(std::get((*hist)[0].event) == SubstrateEvent::Acquire); CHECK((*hist)[0].location_label == "MOD"); // Entry 1: processing-axis StartProcessing. CHECK((*hist)[1].processing_to == SubstrateProcessingState::InProcess); CHECK(std::holds_alternative((*hist)[1].event)); CHECK(std::get((*hist)[1].event) == SubstrateProcessingEvent::StartProcessing); // Entry 2: processing-axis EndProcessing. CHECK((*hist)[2].processing_to == SubstrateProcessingState::Processed); CHECK(std::get((*hist)[2].event) == SubstrateProcessingEvent::EndProcessing); } fs::remove_all(dir); } TEST_CASE("SubstrateStore: v1 record on disk still loads (empty history)") { auto dir = scratch_dir("history-v1"); { std::ofstream f(dir / "0000000001.sub", std::ios::binary); // v1: magic 0xC6, version 1, loc=0 proc=0 id=0 slot=0 const uint8_t header[6] = {0xC6, 0x01, 0x00, 0x00, 0x00, 0x00}; f.write(reinterpret_cast(header), 6); auto write_str = [&](const std::string& s) { uint16_t n = static_cast(s.size()); uint8_t lb[2] = {static_cast(n >> 8), static_cast(n)}; f.write(reinterpret_cast(lb), 2); f.write(s.data(), n); }; write_str("W-LEGACY"); write_str(""); write_str(""); } SubstrateStore s; s.enable_persistence(dir); REQUIRE(s.has("W-LEGACY")); CHECK(s.history("W-LEGACY")->empty()); fs::remove_all(dir); } TEST_CASE("SubstrateStore: history timestamps preserve inter-event spacing") { // Two events fired ~10 ms apart should land ~10 ms apart in the // restored history regardless of how long the "restart" gap is. auto dir = scratch_dir("history-spacing"); using namespace std::chrono_literals; { SubstrateStore s; s.enable_persistence(dir); REQUIRE(s.create("W-T") == SubstrateStore::CreateResult::Created); REQUIRE(s.fire_location_event("W-T", SubstrateEvent::Acquire)); std::this_thread::sleep_for(15ms); REQUIRE(s.fire_processing_event("W-T", SubstrateProcessingEvent::StartProcessing)); } std::this_thread::sleep_for(20ms); { SubstrateStore s; s.enable_persistence(dir); const auto* hist = s.history("W-T"); REQUIRE(hist); REQUIRE(hist->size() == 2); const auto delta = (*hist)[1].at - (*hist)[0].at; // ~15 ms spacing should survive the persist/restore round-trip; // ±10 ms slop for scheduling jitter. CHECK(delta >= 5ms); CHECK(delta <= 60ms); } fs::remove_all(dir); }