Files
EVEngine/test/editor_property_v2.cpp
T

464 lines
22 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "editor/EditorAuthority.h"
#include "editor/EditorCommandService.h"
#include "editor/EditorPropertyModel.h"
#include "editor/EditorPropertyPresenter.h"
#include "editor/EditorSession.h"
#include "editor/EditorTransactionService.h"
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <utility>
using namespace eve::editor;
namespace {
class TransformPropertyTarget final : public IDomainOperationTarget, public IDomainOperationTargetStaging {
public:
explicit TransformPropertyTarget(unsigned long long initialRevision = 0) : revision_(initialRevision) {}
TargetId targetId() const override { return TargetId(id_); }
std::uint64_t revision() const override { return revision_; }
EditRegion dirtyRegion() const override { return {}; }
void clearDirtyRegion() override {}
[[nodiscard]] std::unique_ptr<IDomainOperationTarget> cloneDomainState() const override {
return std::make_unique<TransformPropertyTarget>(*this);
}
[[nodiscard]] EditorResult<void> commitDomainState(std::unique_ptr<IDomainOperationTarget> candidate) override {
auto* typed = dynamic_cast<TransformPropertyTarget*>(candidate.get());
if (!typed)
return eve::editing::failed<void>(EditorStatus::Rejected, RuleId("scene.property.candidate"),
"Compensation candidate has an incompatible type");
position_ = typed->position_;
revision_ = typed->revision_;
return eve::editing::applied<void>();
}
EditorResult<void> applyDomainOperation(const DomainOperation& operation) override {
if (operation.type != "scene.transform.position.set.v1")
return eve::editing::failed<void>(EditorStatus::Unsupported, RuleId("scene.property.unsupported"),
"Unsupported property operation");
if (!operation.payload.getIf<EditorValue::Array>())
return eve::editing::failed<void>(EditorStatus::Rejected, RuleId("scene.property.invalid-value"),
"Position must be an array");
position_ = operation.payload;
++revision_;
return eve::editing::applied<void>();
}
const EditorValue& position() const { return position_; }
void externalSet(EditorValue position) {
position_ = std::move(position);
++revision_;
}
private:
std::string id_ = "scene-property-target";
unsigned long long revision_ = 0;
EditorValue position_ = EditorValue::Array{0.0, 0.0, 0.0};
};
class TransformPropertyProvider final : public IPropertyProvider {
public:
explicit TransformPropertyProvider(TransformPropertyTarget* target) : target_(target) {}
[[nodiscard]] eve::Result<eve::Revision> currentRevision(const SelectionSnapshot&) const override {
return eve::Result<eve::Revision>::success(eve::Revision(target_->revision()));
}
PropertySchema schema(const SelectionSnapshot&) const override {
PropertySchema result;
result.typeId = "scene.transform";
PropertyDescriptor position;
position.path = PropertyPath("transform.position");
position.displayNameKey = "editor.transform.position";
position.category = "transform";
position.type = PropertyType::Vec3;
position.flags = PropertyFlag::Runtime | PropertyFlag::MultiEdit;
position.defaultValue = EditorValue::Array{0.0, 0.0, 0.0};
result.properties.push_back(std::move(position));
PropertyDescriptor debug;
debug.path = PropertyPath("debug.internal-name");
debug.displayNameKey = "editor.debug.internal-name";
debug.category = "debug";
debug.type = PropertyType::String;
debug.flags = PropertyFlag::Advanced | PropertyFlag::EditorOnly | PropertyFlag::ReadOnly;
debug.defaultValue = "SceneObject";
result.properties.push_back(std::move(debug));
return result;
}
PropertyReadResult read(const SelectionSnapshot&, const PropertyPath& path) const override {
if (path == PropertyPath("transform.position")) return {PropertyReadState::Value, target_->position(), {}};
if (path == PropertyPath("debug.internal-name"))
return {PropertyReadState::Value, EditorValue("SceneObject"), {}};
return {};
}
EditorResult<DomainOperation> makeSet(const SelectionSnapshot&, const PropertyPath& path, const EditorValue& value,
PropertySetMode mode) const override {
if (path != PropertyPath("transform.position") || mode != PropertySetMode::Absolute)
return eve::editing::failed<DomainOperation>(EditorStatus::Unsupported, RuleId("scene.property.unsupported"),
"Only absolute position changes are supported");
DomainOperation operation;
operation.type = "scene.transform.position.set.v1";
operation.target = TargetId(target_->targetId());
operation.payload = value;
operation.inverse = target_->position();
operation.hasInverse = true;
operation.affectedProperties.push_back(path.value());
return eve::editing::applied<DomainOperation>(std::move(operation));
}
EditorResult<DomainOperation> makeReset(const SelectionSnapshot& selection,
const PropertyPath& path) const override {
return makeSet(selection, path, EditorValue::Array{0.0, 0.0, 0.0}, PropertySetMode::Absolute);
}
private:
TransformPropertyTarget* target_ = nullptr;
};
class LegacyPropertyProvider final : public IPropertyProvider {
public:
explicit LegacyPropertyProvider(TransformPropertyProvider provider) : provider_(std::move(provider)) {}
PropertySchema schema(const SelectionSnapshot& selection) const override { return provider_.schema(selection); }
PropertyReadResult read(const SelectionSnapshot& selection, const PropertyPath& path) const override {
return provider_.read(selection, path);
}
EditorResult<DomainOperation> makeSet(const SelectionSnapshot& selection, const PropertyPath& path,
const EditorValue& value, PropertySetMode mode) const override {
return provider_.makeSet(selection, path, value, mode);
}
EditorResult<DomainOperation> makeReset(const SelectionSnapshot& selection,
const PropertyPath& path) const override {
return provider_.makeReset(selection, path);
}
private:
TransformPropertyProvider provider_;
};
SelectionSnapshot sceneSelection(const TransformPropertyTarget& target) {
SelectionSnapshot selection;
selection.channel = "global.scene";
SelectionItem item;
item.domain = SelectionDomain::Scene;
item.target = TargetId(target.targetId());
item.item = StableId("object-1");
item.type = "scene.object";
selection.items.push_back(item);
selection.primary = item;
return selection;
}
} // namespace
TEST_CASE("editor.v2.property_presenters_share_schema_and_command_intent") {
TransformPropertyTarget target;
TransformPropertyProvider provider(&target);
SelectionSnapshot selection = sceneSelection(target);
PropertySchema schema = provider.schema(selection);
DeveloperPropertyPresenter developerPresenter;
RuntimePropertyPresenter runtimePresenter;
HostProfile runtime = HostProfile::runtimeBuilder();
runtime.allowCommand(CommandId("editor.property.set"));
PropertyPresentation developer = developerPresenter.present(schema, selection, provider);
PropertyPresentation game = runtimePresenter.present(schema, selection, provider, runtime);
CHECK_EQ(developer.rows.size(), static_cast<std::size_t>(2));
CHECK_EQ(game.rows.size(), static_cast<std::size_t>(1));
CHECK_EQ(game.rows.front().descriptor.path.value(), std::string("transform.position"));
EditorValue desired = EditorValue::Array{1.0, 2.0, 3.0};
auto developerIntent =
developerPresenter.editIntent(schema, selection, PropertyPath("transform.position"), desired);
auto runtimeIntent =
runtimePresenter.editIntent(schema, selection, PropertyPath("transform.position"), desired, runtime);
CHECK(developerIntent.ok());
CHECK(runtimeIntent.ok());
CHECK(developerIntent.value().command == runtimeIntent.value().command);
CHECK(developerIntent.value().payload == runtimeIntent.value().payload);
CHECK(target.position() != desired);
auto hidden = runtimePresenter.editIntent(schema, selection, PropertyPath("debug.internal-name"),
EditorValue("changed"), runtime);
CHECK_EQ(static_cast<int>(hidden.code()), static_cast<int>(EditorStatus::Rejected));
}
TEST_CASE("editor.v2.developer_and_game_property_ui_execute_same_command") {
TransformPropertyTarget target;
TransformPropertyProvider provider(&target);
SelectionSnapshot selection = sceneSelection(target);
PropertySchema schema = provider.schema(selection);
LocalWorldAuthority authority(&target);
LocalTransactionBackend backend(&authority);
EditorCommandService commands;
CommandDescriptor descriptor;
descriptor.id = CommandId("editor.property.set");
descriptor.ownerModule = "editor";
descriptor.requiredFeatures = HostFeature::RuntimeWorld;
CHECK(commands
.registerPlannedCommand(
descriptor,
[&](const CommandRequest& request) {
const auto* payload = request.payload.getIf<EditorValue::Object>();
if (!payload || !payload->contains("path") || !payload->contains("value"))
return eve::editing::failed<CommandPlan>(EditorStatus::Rejected,
RuleId("editor.property.payload"),
"Property command payload is incomplete");
const auto* path = payload->at("path").getIf<std::string>();
if (!path)
return eve::editing::failed<CommandPlan>(
EditorStatus::Rejected, RuleId("editor.property.path"), "Property path must be a string");
auto operation = provider.makeSet(selection, PropertyPath(*path), payload->at("value"),
PropertySetMode::Absolute);
if (!operation.ok()) return EditorResult<CommandPlan>::failure(operation.status());
CommandPlan plan;
plan.operations.push_back(std::move(operation).takeValue());
return eve::editing::applied<CommandPlan>(std::move(plan));
},
[&](const CommandRequest&, const CommandPlan& plan) {
TransactionSpec specification;
specification.id = TransactionId(plan.id.value());
specification.label = "Set property";
specification.target = plan.target;
specification.baseRevision = plan.baseRevision;
auto begun = backend.begin(std::move(specification));
if (!begun.ok())
return eve::editing::failed<TransactionReceipt>(begun.code(), RuleId("editor.property.begin"),
"Could not begin property transaction");
for (const DomainOperation& operation : plan.operations) {
const auto appended = backend.append(operation);
if (!appended.ok())
return eve::editing::failed<TransactionReceipt>(
EditorStatus::Failed, RuleId("editor.property.append"),
"Could not append the planned property operation");
}
return backend.commit();
})
.ok());
DeveloperPropertyPresenter developerPresenter;
RuntimePropertyPresenter gamePresenter;
EditorSession developerSession;
developerSession.setCommandService(&commands);
developerSession.bindTarget(&target);
EditorValue firstPosition = EditorValue::Array{4.0, 5.0, 6.0};
auto developerIntent =
developerPresenter.editIntent(schema, selection, PropertyPath("transform.position"), firstPosition);
auto developerPlan = developerSession.planCommand(developerIntent.value().command, developerIntent.value().payload);
CHECK(developerPlan.ok());
CHECK(developerSession.executePlan(developerPlan.value(), developerIntent.value().payload).ok());
CHECK(target.position() == firstPosition);
HostProfile runtime = HostProfile::runtimeBuilder();
runtime.allowCommand(descriptor.id);
EditorSession gameSession;
gameSession.setHostProfile(runtime);
gameSession.setCommandService(&commands);
gameSession.bindTarget(&target);
EditorValue secondPosition = EditorValue::Array{7.0, 8.0, 9.0};
auto gameIntent =
gamePresenter.editIntent(schema, selection, PropertyPath("transform.position"), secondPosition, runtime);
auto gamePlan = gameSession.planCommand(gameIntent.value().command, gameIntent.value().payload);
CHECK(gamePlan.ok());
CHECK(gameSession.executePlan(gamePlan.value(), gameIntent.value().payload).ok());
CHECK(target.position() == secondPosition);
CHECK(backend.canUndo());
}
TEST_CASE("editor.v2.property_schema_rejects_type_range_and_read_only") {
PropertyDescriptor property;
property.path = PropertyPath("speed");
property.type = PropertyType::Float;
property.numeric.minimum = 0.0;
property.numeric.maximum = 10.0;
CHECK(validatePropertyValue(property, EditorValue(5.0)).ok());
CHECK_EQ(static_cast<int>(validatePropertyValue(property, EditorValue("fast")).code()),
static_cast<int>(EditorStatus::Rejected));
CHECK_EQ(static_cast<int>(validatePropertyValue(property, EditorValue(12.0)).code()),
static_cast<int>(EditorStatus::Rejected));
property.flags = PropertyFlag::ReadOnly;
CHECK_EQ(static_cast<int>(validatePropertyValue(property, EditorValue(5.0)).code()),
static_cast<int>(EditorStatus::Rejected));
}
TEST_CASE("editor.v2.property_model_uses_transaction_backend_for_commit_undo_and_redo") {
TransformPropertyTarget target;
TransformPropertyProvider provider(&target);
SelectionSnapshot selection = sceneSelection(target);
LocalWorldAuthority authority(&target);
LocalTransactionBackend backend(&authority);
EditorPropertyModel model(provider.schema(selection), selection, &provider, PropertyModelSurface::Developer,
HostProfile::developer(), &backend);
bool legacySinkCalled = false;
model.setEditSink([&](const PropertyEditIntent&) {
legacySinkCalled = true;
return eve::editing::applied<void>();
});
const EditorValue desired = EditorValue::Array{10.0, 11.0, 12.0};
const auto write = model.write("transform.position", toPresentationValue(desired));
CHECK(write.accepted);
CHECK(!legacySinkCalled);
CHECK(target.position() == desired);
CHECK(backend.canUndo());
auto undone = model.undo();
CHECK(undone.ok());
CHECK(!undone.value().id.empty());
CHECK(target.position() != desired);
auto redone = model.redo();
CHECK(redone.ok());
CHECK(!redone.value().id.empty());
CHECK(target.position() == desired);
}
TEST_CASE("editor.v2.property_model_explicit_transaction_previews_without_mutation") {
TransformPropertyTarget target;
TransformPropertyProvider provider(&target);
SelectionSnapshot selection = sceneSelection(target);
LocalWorldAuthority authority(&target);
LocalTransactionBackend backend(&authority);
EditorPropertyModel model(provider.schema(selection), selection, &provider, PropertyModelSurface::Developer,
HostProfile::developer(), &backend);
const EditorValue desired = EditorValue::Array{13.0, 14.0, 15.0};
auto begun = model.beginTransaction("Set transform position");
CHECK(begun.ok());
const auto staged = model.write("transform.position", toPresentationValue(desired));
CHECK(staged.accepted);
CHECK(target.position() != desired);
auto previewed = model.previewTransaction();
CHECK(previewed.ok());
CHECK(target.position() != desired);
auto committed = model.commitTransaction();
CHECK(committed.ok());
CHECK(target.position() == desired);
}
TEST_CASE("editor.v2.property_model_failed_commit_keeps_target_unchanged_and_is_discardable") {
TransformPropertyTarget target;
TransformPropertyProvider provider(&target);
SelectionSnapshot selection = sceneSelection(target);
ReadOnlyAuthority authority;
LocalTransactionBackend backend(&authority);
EditorPropertyModel model(provider.schema(selection), selection, &provider, PropertyModelSurface::Developer,
HostProfile::developer(), &backend);
const EditorValue before = target.position();
const auto failed = model.write("transform.position", toPresentationValue(EditorValue::Array{16.0, 17.0, 18.0}));
CHECK(!failed.accepted);
CHECK(target.position() == before);
CHECK(backend.active());
auto discarded = model.rollbackTransaction();
CHECK(discarded.ok());
CHECK(target.position() == before);
CHECK(!backend.active());
}
TEST_CASE("editor.v2.property_model_binds_non_zero_provider_revision") {
TransformPropertyTarget target(41);
TransformPropertyProvider provider(&target);
SelectionSnapshot selection = sceneSelection(target);
LocalWorldAuthority authority(&target);
LocalTransactionBackend backend(&authority);
EditorPropertyModel model(provider.schema(selection), selection, &provider, PropertyModelSurface::Developer,
HostProfile::developer(), &backend);
CHECK_EQ(model.targetRevision().value(), static_cast<std::uint64_t>(41));
const auto write = model.write("transform.position", toPresentationValue(EditorValue::Array{1.0, 2.0, 3.0}));
CHECK(write.accepted);
CHECK_EQ(model.targetRevision().value(), static_cast<std::uint64_t>(42));
}
TEST_CASE("editor.v2.property_model_rejects_external_change_until_refresh_rebase") {
TransformPropertyTarget target(7);
TransformPropertyProvider provider(&target);
SelectionSnapshot selection = sceneSelection(target);
LocalWorldAuthority authority(&target);
LocalTransactionBackend backend(&authority);
EditorPropertyModel model(provider.schema(selection), selection, &provider, PropertyModelSurface::Developer,
HostProfile::developer(), &backend);
const EditorValue external = EditorValue::Array{20.0, 21.0, 22.0};
target.externalSet(external);
const EditorValue beforeRejectedWrite = target.position();
const auto rejected = model.write("transform.position", toPresentationValue(EditorValue::Array{8.0, 9.0, 10.0}));
CHECK(!rejected.accepted);
CHECK_EQ(rejected.code, std::string("editor.property.revision-conflict"));
CHECK(target.position() == beforeRejectedWrite);
CHECK_EQ(model.targetRevision().value(), static_cast<std::uint64_t>(7));
const auto refreshed = model.refresh();
CHECK(refreshed.ok());
CHECK_EQ(model.targetRevision().value(), static_cast<std::uint64_t>(8));
CHECK(model.read("transform.position") == std::optional<eve::Value>(toPresentationValue(external)));
const auto accepted = model.write("transform.position", toPresentationValue(EditorValue::Array{8.0, 9.0, 10.0}));
CHECK(accepted.accepted);
CHECK_EQ(model.targetRevision().value(), static_cast<std::uint64_t>(9));
}
TEST_CASE("editor.v2.property_model_external_change_conflict_preserves_failed_transaction_state") {
TransformPropertyTarget target(12);
TransformPropertyProvider provider(&target);
SelectionSnapshot selection = sceneSelection(target);
LocalWorldAuthority authority(&target);
LocalTransactionBackend backend(&authority);
EditorPropertyModel model(provider.schema(selection), selection, &provider, PropertyModelSurface::Developer,
HostProfile::developer(), &backend);
const auto begun = model.beginTransaction("stale property edit");
CHECK(begun.ok());
const auto staged = model.write("transform.position", toPresentationValue(EditorValue::Array{30.0, 31.0, 32.0}));
CHECK(staged.accepted);
const EditorValue external = EditorValue::Array{40.0, 41.0, 42.0};
target.externalSet(external);
const auto failed = model.commitTransaction();
CHECK(!failed.ok());
CHECK_EQ(static_cast<int>(failed.code()), static_cast<int>(EditorStatus::Conflict));
CHECK(target.position() == external);
CHECK(backend.active());
const auto discarded = model.rollbackTransaction();
CHECK(discarded.ok());
CHECK(target.position() == external);
CHECK(!backend.active());
CHECK_EQ(model.targetRevision().value(), static_cast<std::uint64_t>(12));
}
TEST_CASE("editor.v2.legacy_property_provider_fails_closed_without_implicit_zero_revision") {
TransformPropertyTarget target(0);
TransformPropertyProvider revisionAware(&target);
LegacyPropertyProvider legacy(std::move(revisionAware));
SelectionSnapshot selection = sceneSelection(target);
EditorPropertyModel model(legacy.schema(selection), selection, &legacy);
model.setEditSink([](const PropertyEditIntent&) { return eve::editing::applied<void>(); });
const auto rejected = model.write("transform.position", toPresentationValue(EditorValue::Array{2.0, 3.0, 4.0}));
CHECK(!rejected.accepted);
CHECK_EQ(rejected.code, std::string("editor.property.revision-unsupported"));
const EditorValue expected = EditorValue::Array{2.0, 3.0, 4.0};
CHECK(target.position() != expected);
}