Files
secs-gem/tests/test_exception_persistence.cpp
T
raphael b3bde7f087 persistence: ExceptionStore enable_persistence(dir)
Per-EXID binary record (.ex), magic + version + atomic .tmp+rename.
Records full E5 §9 lifecycle: state, EXID, EXTYPE, EXMESSAGE, and
the candidate EXRECVRA list.

Cleared exceptions are terminal — the FSM transitions through
Cleared remove the in-memory entry AND delete the journal file
(matching the existing in-memory semantics).  Recovering /
RecoverFailed states survive restart: the application can decide
on replay whether to retry recovery or abort.

Five new tests cover post+replay, Recovering-survives-restart,
autonomous-clear cleanup, RecoverFailed retry post-restart, and
corrupt-record drop.

This completes #12 in the test-gap backlog (persistence for the four
in-memory stores beyond Spool).

Closes #4 in the test-gap backlog.

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

150 lines
4.4 KiB
C++

// Persistence tests for ExceptionStore.
#include <doctest/doctest.h>
#include <filesystem>
#include <fstream>
#include <random>
#include <string>
#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<std::string>{"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);
}