// Persistence tests for ExceptionStore. #include #include #include #include #include #include "secsgem/gem/store/exceptions.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-ex-") + 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("ExceptionStore persistence: post + replay reproduces full record") { auto dir = scratch_dir("post-replay"); { ExceptionStore s; s.enable_persistence(dir); REQUIRE(s.post(42, "VACUUM", "lost vacuum in chamber A", {"PURGE", "RECOVER", "ABORT"}) == ExceptionStore::PostResult::Posted); REQUIRE(s.post(99, "OVERTEMP", "process tube > 850C", {"COOL"}) == ExceptionStore::PostResult::Posted); CHECK(count_with_ext(dir, ".ex") == 2); } { ExceptionStore s; s.enable_persistence(dir); REQUIRE(s.has(42)); REQUIRE(s.has(99)); const auto* e42 = s.get(42); CHECK(e42->extype == "VACUUM"); CHECK(e42->exmessage == "lost vacuum in chamber A"); CHECK(e42->exrecvra == std::vector{"PURGE", "RECOVER", "ABORT"}); CHECK(e42->fsm->state() == ExceptionState::Posted); const auto* e99 = s.get(99); CHECK(e99->exrecvra.size() == 1); CHECK(e99->exrecvra[0] == "COOL"); } fs::remove_all(dir); } TEST_CASE("ExceptionStore persistence: Recovering state survives restart") { auto dir = scratch_dir("recover"); { ExceptionStore s; s.enable_persistence(dir); REQUIRE(s.post(1, "GAS", "leak", {"PURGE"}) == ExceptionStore::PostResult::Posted); CHECK(s.on_recover(1, "PURGE") == AlarmAck::Accept); CHECK(s.state(1) == ExceptionState::Recovering); } { ExceptionStore s; s.enable_persistence(dir); REQUIRE(s.has(1)); CHECK(s.state(1) == ExceptionState::Recovering); // Application decides to complete recovery — state moves to Cleared, // which terminally removes the entry + deletes the journal file. REQUIRE(s.fire_internal(1, ExceptionEvent::RecoveryComplete)); CHECK_FALSE(s.has(1)); CHECK(count_with_ext(dir, ".ex") == 0); } fs::remove_all(dir); } TEST_CASE("ExceptionStore persistence: autonomous Clear deletes journal") { auto dir = scratch_dir("autoclear"); { ExceptionStore s; s.enable_persistence(dir); REQUIRE(s.post(7, "RF", "rf fault", {"RESET"}) == ExceptionStore::PostResult::Posted); CHECK(count_with_ext(dir, ".ex") == 1); REQUIRE(s.fire_internal(7, ExceptionEvent::Clear)); CHECK_FALSE(s.has(7)); CHECK(count_with_ext(dir, ".ex") == 0); } { ExceptionStore s; s.enable_persistence(dir); CHECK(s.size() == 0); } fs::remove_all(dir); } TEST_CASE("ExceptionStore persistence: RecoverFailed survives restart and retry works") { auto dir = scratch_dir("retry"); { ExceptionStore s; s.enable_persistence(dir); REQUIRE(s.post(3, "FLOW", "MFC stuck", {"RETRY", "ABORT"}) == ExceptionStore::PostResult::Posted); CHECK(s.on_recover(3, "RETRY") == AlarmAck::Accept); REQUIRE(s.fire_internal(3, ExceptionEvent::RecoveryFailed)); CHECK(s.state(3) == ExceptionState::RecoverFailed); } { ExceptionStore s; s.enable_persistence(dir); CHECK(s.state(3) == ExceptionState::RecoverFailed); // Recovery from RecoverFailed is legal — host can retry. CHECK(s.on_recover(3, "RETRY") == AlarmAck::Accept); CHECK(s.state(3) == ExceptionState::Recovering); } fs::remove_all(dir); } TEST_CASE("ExceptionStore persistence: corrupt file dropped, others survive") { auto dir = scratch_dir("corrupt"); { ExceptionStore s; s.enable_persistence(dir); REQUIRE(s.post(11, "T", "M", {"R"}) == ExceptionStore::PostResult::Posted); } { std::ofstream f(dir / "9999999999.ex", std::ios::binary); const char junk[] = {0x00, 0x01, 0x02}; f.write(junk, sizeof(junk)); } ExceptionStore s; s.enable_persistence(dir); CHECK(s.has(11)); CHECK(s.size() == 1); fs::remove_all(dir); }