#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "editor/EditorAuthority.h" #include "editor/EditorTransactionConsumer.h" #include "map/level/editing/FieldTargets.h" #include "map/level/editing/TileBuffer.h" #include #include #include #include #include #include #include namespace { using eve::Diagnostic; using eve::DiagnosticCode; using eve::Result; using eve::StatusCode; using eve::editor::DomainOperation; using eve::editor::EditorCommitState; using eve::editor::EditorResult; using eve::editor::EditorStatus; using eve::editor::EditorTransactionConsumer; using eve::editor::IEditAuthority; using eve::editor::IEditCommand; using eve::editor::TransactionId; using eve::editor::TransactionReceipt; using eve::editor::TransactionSpec; using eve::transaction::ITransactionParticipant; using eve::transaction::TransactionContext; class FailingParticipant final : public ITransactionParticipant { public: std::string_view name() const noexcept override { return "test.second-participant"; } Result prepare(const TransactionContext&) override { ++prepareCount; prepared = true; return Result::success(); } Result commit(const TransactionContext&) override { ++commitCount; if (failCommit) return Result::failure( Diagnostic::error(DiagnosticCode::Failed, "injected second participant failure")); prepared = false; return Result::success(); } Result rollback(const TransactionContext&) override { ++rollbackCount; prepared = false; return Result::success(); } Result compensate(const TransactionContext&) override { ++compensateCount; return Result::success(); } int prepareCount = 0; int commitCount = 0; int rollbackCount = 0; int compensateCount = 0; bool prepared = false; bool failCommit = true; }; class ThrowingMergeCommand final : public IEditCommand { public: ThrowingMergeCommand(std::string name, int* value, int after) : name_(std::move(name)), value_(value), after_(after) {} const std::string& name() const override { return name_; } bool apply() override { if (!value_) return false; before_ = *value_; *value_ = after_; return true; } void revert() override { if (value_) *value_ = before_; } [[nodiscard]] std::unique_ptr clone() const override { return std::make_unique(*this); } bool mergeWith(const IEditCommand&) override { throw std::runtime_error("injected merge failure"); } eve::editor::EditRegion dirtyRegion() const override { return {}; } private: std::string name_; int* value_ = nullptr; int before_ = 0; int after_ = 0; }; class RejectingAuthority final : public IEditAuthority { public: EditorResult preflight(const TransactionSpec&, std::span) override { ++preflightCount; return eve::editing::failed( EditorStatus::Conflict, eve::editor::RuleId("test.preflight.rejected"), "injected preflight rejection"); } EditorResult commit(const eve::editor::AuthorityPlan&) override { ++commitCount; return eve::editing::failed(EditorStatus::Failed, eve::editor::RuleId("test.commit.unexpected"), "commit must not be called after preflight rejection"); } EditorResult compensate(const TransactionReceipt&) override { ++compensateCount; return eve::editing::failed(EditorStatus::Failed, eve::editor::RuleId("test.compensate.unexpected"), "compensation must not be called after preflight rejection"); } int preflightCount = 0; int commitCount = 0; int compensateCount = 0; }; std::unique_ptr makeTileCommand(eve::level_editing::TileBufferTarget& target, int value) { auto command = std::make_unique("set-tile", &target); if (!command->record(0, 0, value)) return nullptr; return command; } TransactionSpec commandSpec(std::string id) { TransactionSpec specification; specification.id = TransactionId(std::move(id)); specification.label = "test command"; return specification; } DomainOperation rejectedOperation() { DomainOperation operation; operation.type = "test.operation"; operation.target = eve::editor::TargetId("test-target"); return operation; } } // namespace TEST_CASE("editor.transactionConsumer.commitUndoRedoAndDryRun") { eve::level_editing::TileBuffer buffer(1, 1); eve::level_editing::TileBufferTarget target("tiles", &buffer); EditorTransactionConsumer consumer; auto begun = consumer.begin(commandSpec("editor.command.1")); REQUIRE(begun.ok()); auto command = makeTileCommand(target, 7); REQUIRE(static_cast(command)); auto appended = consumer.append(std::move(command)); REQUIRE(appended.ok()); CHECK_EQ(buffer.getGid(0, 0), 0); auto dryRun = consumer.dryRun(); REQUIRE(dryRun.ok()); CHECK_EQ(dryRun.value().commandCount, size_t{1}); CHECK_EQ(buffer.getGid(0, 0), 0); auto committed = consumer.commit(); REQUIRE(committed.ok()); CHECK_EQ(static_cast(committed.value().coordinator.state), static_cast(eve::transaction::CoordinatorState::Committed)); CHECK_EQ(buffer.getGid(0, 0), 7); CHECK(consumer.canUndo()); auto undone = consumer.undo(); REQUIRE(undone.ok()); CHECK_EQ(static_cast(undone.value().coordinator.state), static_cast(eve::transaction::CoordinatorState::Compensated)); CHECK_EQ(buffer.getGid(0, 0), 0); CHECK(consumer.canRedo()); auto redone = consumer.redo(); REQUIRE(redone.ok()); CHECK_EQ(static_cast(redone.value().coordinator.state), static_cast(eve::transaction::CoordinatorState::Committed)); CHECK_EQ(buffer.getGid(0, 0), 7); } TEST_CASE("editor.transactionConsumer.authorityPreflightRejectsWithoutCommit") { RejectingAuthority authority; EditorTransactionConsumer consumer(&authority); TransactionSpec specification; specification.id = TransactionId("editor.authority.rejected"); specification.target = eve::editor::TargetId("test-target"); REQUIRE(consumer.begin(std::move(specification)).ok()); REQUIRE(consumer.append(rejectedOperation()).ok()); auto dryRun = consumer.dryRun(); CHECK(!dryRun.ok()); CHECK_EQ(static_cast(dryRun.code()), static_cast(StatusCode::Conflict)); CHECK_EQ(authority.preflightCount, 1); CHECK_EQ(authority.commitCount, 0); auto committed = consumer.commit(); CHECK(!committed.ok()); CHECK_EQ(authority.preflightCount, 2); CHECK_EQ(authority.commitCount, 0); CHECK(!consumer.canUndo()); } TEST_CASE("editor.transactionConsumer.secondParticipantFailureCompensatesEditor") { eve::level_editing::TileBuffer buffer(1, 1); eve::level_editing::TileBufferTarget target("tiles", &buffer); EditorTransactionConsumer consumer; REQUIRE(consumer.begin(commandSpec("editor.composed.failure")).ok()); auto command = makeTileCommand(target, 9); REQUIRE(static_cast(command)); REQUIRE(consumer.append(std::move(command)).ok()); FailingParticipant second; std::array additional{&second}; auto result = consumer.commit(std::span(additional.data(), additional.size())); CHECK(!result.ok()); CHECK_EQ(buffer.getGid(0, 0), 0); CHECK_EQ(second.prepareCount, 1); CHECK_EQ(second.commitCount, 1); CHECK_EQ(second.rollbackCount, 1); CHECK_EQ(second.compensateCount, 0); CHECK(!consumer.canUndo()); CHECK(consumer.active()); CHECK_EQ(static_cast(consumer.state()), static_cast(EditorCommitState::FailedRetryable)); CHECK(!consumer.diagnostics().empty()); second.failCommit = false; auto retried = consumer.retry(std::span(additional.data(), additional.size())); REQUIRE(retried.ok()); CHECK_EQ(buffer.getGid(0, 0), 9); CHECK_EQ(second.commitCount, 2); CHECK_EQ(static_cast(consumer.state()), static_cast(EditorCommitState::Committed)); auto duplicateCommit = consumer.commit(std::span(additional.data(), additional.size())); CHECK(!duplicateCommit.ok()); CHECK_EQ(second.commitCount, 2); } TEST_CASE("editor.transactionConsumer.previewMergeFailureDoesNotMutateTarget") { int value = 0; EditorTransactionConsumer consumer; REQUIRE(consumer.begin(commandSpec("editor.preview.merge-failure")).ok()); auto first = std::make_unique("preview", &value, 1); REQUIRE(consumer.appendPreview(std::move(first)).ok()); CHECK_EQ(value, 1); auto second = std::make_unique("preview", &value, 2); auto failed = consumer.appendPreview(std::move(second)); CHECK(!failed.ok()); CHECK_EQ(value, 1); auto discarded = consumer.discard(); REQUIRE(discarded.ok()); CHECK_EQ(value, 0); CHECK(!consumer.active()); CHECK_EQ(static_cast(consumer.state()), static_cast(EditorCommitState::Discarded)); } TEST_CASE("editor.transactionConsumer.explicitDiscardClearsPendingWork") { eve::level_editing::TileBuffer buffer(1, 1); eve::level_editing::TileBufferTarget target("tiles", &buffer); EditorTransactionConsumer consumer; REQUIRE(consumer.begin(commandSpec("editor.explicit-discard")).ok()); auto command = makeTileCommand(target, 4); REQUIRE(static_cast(command)); REQUIRE(consumer.appendPreview(std::move(command)).ok()); CHECK_EQ(buffer.getGid(0, 0), 4); auto discarded = consumer.discard(); REQUIRE(discarded.ok()); CHECK_EQ(buffer.getGid(0, 0), 0); CHECK(!consumer.active()); auto retry = consumer.retry(); CHECK(!retry.ok()); }