467 lines
23 KiB
C++
467 lines
23 KiB
C++
#include "editor/EditorSceneComponentPayload.h"
|
|
#include "editor/EditorSceneTarget.h"
|
|
#include "editor/EditorTransactionService.h"
|
|
#include "editor/EditorAudioTarget.h"
|
|
#include "editor/EditorMaterialTarget.h"
|
|
#include "editor/EditorPhysicsTarget.h"
|
|
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include <memory>
|
|
|
|
using namespace eve::editor;
|
|
|
|
namespace {
|
|
|
|
const EditorValue* field(const EditorValue& value, const char* name) {
|
|
const auto* object = value.getIf<EditorValue::Object>();
|
|
if (!object) return nullptr;
|
|
const auto found = object->find(name);
|
|
return found == object->end() ? nullptr : &found->second;
|
|
}
|
|
|
|
class HealthPayloadProvider final : public ISceneComponentPayloadProvider,
|
|
public IDomainOperationTarget,
|
|
public IDomainOperationTargetStaging {
|
|
public:
|
|
explicit HealthPayloadProvider(std::string id = "health-payload") : id_(std::move(id)) {}
|
|
|
|
const std::string& componentType() const override {
|
|
static const std::string type = "health";
|
|
return type;
|
|
}
|
|
const std::string& targetId() const override { return id_; }
|
|
unsigned long long revision() const override { return revision_; }
|
|
EditRegion dirtyRegion() const override { return {}; }
|
|
void clearDirtyRegion() override {}
|
|
|
|
std::vector<SceneComponentPayloadRef> components(const TargetId& scene,
|
|
const ObjectId& object) const override {
|
|
if (object != ObjectId("hero")) return {};
|
|
return {{scene, object, StableId("hero.health"), componentType(), generation_, revision_}};
|
|
}
|
|
|
|
EditorResult<IDomainOperationTarget*> payloadOperationTarget(
|
|
const SelectionSnapshot&) const override {
|
|
return EditorResult<IDomainOperationTarget*>::applied(
|
|
const_cast<HealthPayloadProvider*>(this));
|
|
}
|
|
|
|
std::vector<EditorDiagnostic> validateComponent(
|
|
const SceneComponentPayloadRef& component) const override {
|
|
if (component.generation != generation_)
|
|
return {{RuleId("test.health.stale"), DiagnosticSeverity::Error,
|
|
"Health component reference is stale"}};
|
|
return {};
|
|
}
|
|
|
|
eve::Result<eve::Revision> currentRevision(const SelectionSnapshot&) const override {
|
|
return eve::Result<eve::Revision>::success(eve::Revision(revision_));
|
|
}
|
|
|
|
PropertySchema schema(const SelectionSnapshot&) const override {
|
|
PropertySchema result;
|
|
result.typeId = componentType();
|
|
PropertyDescriptor points;
|
|
points.path = PropertyPath("points");
|
|
points.type = PropertyType::Float;
|
|
points.defaultValue = 100.0;
|
|
points.numeric.minimum = 0.0;
|
|
result.properties.push_back(std::move(points));
|
|
return result;
|
|
}
|
|
|
|
PropertyReadResult read(const SelectionSnapshot& selection,
|
|
const PropertyPath& path) const override {
|
|
if (selection.items.size() != 1 || selection.items.front().item != StableId("hero.health") ||
|
|
path != PropertyPath("points"))
|
|
return {};
|
|
return {PropertyReadState::Value, points_, {}};
|
|
}
|
|
|
|
EditorResult<DomainOperation> makeSet(const SelectionSnapshot& selection,
|
|
const PropertyPath& path, const EditorValue& value,
|
|
PropertySetMode mode) const override {
|
|
if (mode != PropertySetMode::Absolute || selection.items.size() != 1 ||
|
|
selection.items.front().item != StableId("hero.health") ||
|
|
path != PropertyPath("points"))
|
|
return EditorResult<DomainOperation>::error(EditorStatus::Rejected,
|
|
RuleId("test.health.selection"), "Invalid health component selection");
|
|
const auto validation = validatePropertyValue(schema(selection).properties.front(), value);
|
|
if (!validation.isAccepted()) {
|
|
EditorResult<DomainOperation> result;
|
|
result.status = validation.status;
|
|
result.diagnostics = validation.diagnostics;
|
|
return result;
|
|
}
|
|
DomainOperation operation;
|
|
operation.type = "test.health.set.v1";
|
|
operation.target = TargetId(id_);
|
|
operation.payload = EditorValue::Object{{"component", "hero.health"}, {"value", value}};
|
|
operation.inverse = EditorValue::Object{{"component", "hero.health"}, {"value", points_}};
|
|
operation.hasInverse = true;
|
|
operation.affectedObjects.push_back({TargetId(id_), "hero.health", generation_});
|
|
operation.affectedProperties.push_back(path.value());
|
|
operation.mergeKey = "health:hero.health:points";
|
|
return EditorResult<DomainOperation>::applied(std::move(operation));
|
|
}
|
|
|
|
EditorResult<DomainOperation> makeReset(const SelectionSnapshot& selection,
|
|
const PropertyPath& path) const override {
|
|
return makeSet(selection, path, 100.0, PropertySetMode::Absolute);
|
|
}
|
|
|
|
EditorResult<void> applyDomainOperation(const DomainOperation& operation) override {
|
|
if (operation.target != TargetId(id_) || operation.type != "test.health.set.v1")
|
|
return EditorResult<void>::error(EditorStatus::Rejected, RuleId("test.health.operation"),
|
|
"Invalid health operation");
|
|
const EditorValue* component = field(operation.payload, "component");
|
|
const EditorValue* value = field(operation.payload, "value");
|
|
const auto* componentId = component ? component->getIf<std::string>() : nullptr;
|
|
const auto* number = value ? value->getIf<double>() : nullptr;
|
|
if (!componentId || *componentId != "hero.health" || !number || *number < 0.0)
|
|
return EditorResult<void>::error(EditorStatus::Rejected, RuleId("test.health.payload"),
|
|
"Invalid health payload");
|
|
points_ = *number;
|
|
++revision_;
|
|
return EditorResult<void>::applied();
|
|
}
|
|
|
|
std::unique_ptr<IDomainOperationTarget> cloneDomainState() const override {
|
|
return std::make_unique<HealthPayloadProvider>(*this);
|
|
}
|
|
|
|
EditorResult<void> commitDomainState(
|
|
std::unique_ptr<IDomainOperationTarget> candidate) override {
|
|
auto* typed = dynamic_cast<HealthPayloadProvider*>(candidate.get());
|
|
if (!typed)
|
|
return EditorResult<void>::error(EditorStatus::Conflict, RuleId("test.health.candidate"),
|
|
"Invalid health candidate");
|
|
*this = *typed;
|
|
return EditorResult<void>::applied();
|
|
}
|
|
|
|
double points() const { return points_; }
|
|
void invalidate() { ++generation_; }
|
|
|
|
private:
|
|
std::string id_;
|
|
unsigned long long revision_ = 1;
|
|
std::uint64_t generation_ = 7;
|
|
double points_ = 80.0;
|
|
};
|
|
|
|
class RecordingAudioSink final : public IAudioSourceRuntimeSink {
|
|
public:
|
|
EditorResult<void> publish(const AudioSourceTarget& candidate) override {
|
|
if (reject)
|
|
return EditorResult<void>::error(EditorStatus::Rejected,
|
|
RuleId("test.audio.runtime-rejected"),
|
|
"Runtime rejected candidate");
|
|
SelectionSnapshot selection;
|
|
selection.items.push_back({SelectionDomain::Scene, TargetId(candidate.targetId()),
|
|
StableId("source"), "audio.source"});
|
|
const auto read = candidate.read(selection, PropertyPath("play.volume"));
|
|
const auto* value = read.value.getIf<double>();
|
|
if (!value)
|
|
return EditorResult<void>::error(EditorStatus::Failed,
|
|
RuleId("test.audio.runtime-value"),
|
|
"Candidate volume is unavailable");
|
|
volume = *value;
|
|
revision = candidate.revision();
|
|
++publishes;
|
|
return EditorResult<void>::applied();
|
|
}
|
|
|
|
bool reject = false;
|
|
double volume = 1.0;
|
|
Revision revision = 0;
|
|
int publishes = 0;
|
|
};
|
|
|
|
class RecordingMaterialSink final : public IMaterialRuntimeSink {
|
|
public:
|
|
EditorResult<void> publish(const MaterialDocumentTarget& candidate) override {
|
|
if (reject)
|
|
return EditorResult<void>::error(EditorStatus::Rejected,
|
|
RuleId("test.material.runtime-rejected"),
|
|
"Runtime rejected material candidate");
|
|
SelectionSnapshot selection;
|
|
selection.items.push_back({SelectionDomain::Asset, TargetId(candidate.targetId()),
|
|
StableId("material"), "graphics.material"});
|
|
const auto read = candidate.read(selection, PropertyPath("shading.roughness"));
|
|
const auto* value = read.value.getIf<double>();
|
|
if (!value)
|
|
return EditorResult<void>::error(EditorStatus::Failed,
|
|
RuleId("test.material.runtime-value"),
|
|
"Candidate roughness is unavailable");
|
|
roughness = *value;
|
|
revision = candidate.revision();
|
|
++publishes;
|
|
return EditorResult<void>::applied();
|
|
}
|
|
|
|
bool reject = false;
|
|
double roughness = 0.45;
|
|
Revision revision = 0;
|
|
int publishes = 0;
|
|
};
|
|
|
|
EditorResult<TransactionReceipt> commit(IDomainOperationTarget& target,
|
|
LocalTransactionBackend& transactions,
|
|
const DomainOperation& operation,
|
|
Revision baseRevision,
|
|
const char* id) {
|
|
TransactionSpec specification;
|
|
specification.id = TransactionId(id);
|
|
specification.label = "Component payload edit";
|
|
specification.target = TargetId(target.targetId());
|
|
specification.baseRevision = baseRevision;
|
|
auto begun = transactions.begin(std::move(specification));
|
|
if (!begun.isAccepted())
|
|
return EditorResult<TransactionReceipt>::error(begun.status, RuleId("test.begin"),
|
|
"Could not begin transaction");
|
|
auto appended = transactions.append(operation);
|
|
if (!appended.isAccepted())
|
|
return EditorResult<TransactionReceipt>::error(appended.status, RuleId("test.append"),
|
|
"Could not append operation");
|
|
return transactions.commit();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("editor.scene.component_payload_registry_routes_schema_transactions_and_undo") {
|
|
HealthPayloadProvider provider;
|
|
SceneComponentPayloadRegistry registry;
|
|
REQUIRE(registry.registerProvider(&provider).isAccepted());
|
|
CHECK_EQ(static_cast<int>(registry.registerProvider(&provider).status),
|
|
static_cast<int>(EditorStatus::Conflict));
|
|
|
|
SceneDocumentTarget scene("scene");
|
|
scene.bindComponentPayloads(®istry);
|
|
auto* payloads = static_cast<ISceneComponentPayloadTarget*>(
|
|
scene.queryCapability(ISceneComponentPayloadTarget::editorCapabilityId()));
|
|
REQUIRE(payloads);
|
|
CHECK_EQ(scene.describe().capabilities.back(),
|
|
ISceneComponentPayloadTarget::editorCapabilityId());
|
|
|
|
auto references = payloads->componentPayloads(TargetId("scene"), ObjectId("hero"));
|
|
REQUIRE(references.value);
|
|
REQUIRE_EQ(references.value->size(), size_t{1});
|
|
CHECK_EQ(references.value->front().component, StableId("hero.health"));
|
|
auto selection = makeSceneComponentSelection("scene-inspector", *references.value, 12);
|
|
REQUIRE(selection.value);
|
|
|
|
auto properties = payloads->propertyProvider(*selection.value);
|
|
auto target = payloads->operationTarget(*selection.value);
|
|
REQUIRE(properties.value);
|
|
REQUIRE(target.value);
|
|
CHECK_EQ((*properties.value)->schema(*selection.value).typeId, std::string("health"));
|
|
CHECK_EQ(*(*properties.value)->read(*selection.value, PropertyPath("points")).value.getIf<double>(),
|
|
80.0);
|
|
|
|
auto operation = (*properties.value)->makeSet(*selection.value, PropertyPath("points"), 25.0,
|
|
PropertySetMode::Absolute);
|
|
REQUIRE(operation.value);
|
|
LocalWorldAuthority authority(*target.value);
|
|
LocalTransactionBackend transactions(&authority);
|
|
const Revision baseRevision = (*target.value)->revision();
|
|
REQUIRE(commit(**target.value, transactions, *operation.value, baseRevision, "health.set").isAccepted());
|
|
CHECK_EQ(provider.points(), 25.0);
|
|
REQUIRE(transactions.undo().isAccepted());
|
|
CHECK_EQ(provider.points(), 80.0);
|
|
|
|
auto stale = commit(**target.value, transactions, *operation.value, baseRevision, "health.stale");
|
|
CHECK_EQ(static_cast<int>(stale.status), static_cast<int>(EditorStatus::Conflict));
|
|
}
|
|
|
|
TEST_CASE("editor.scene.component_payload_registry_rejects_mixed_and_stale_references") {
|
|
HealthPayloadProvider provider;
|
|
SceneComponentPayloadRegistry registry;
|
|
REQUIRE(registry.registerProvider(&provider).isAccepted());
|
|
auto references = registry.componentPayloads(TargetId("scene"), ObjectId("hero"));
|
|
REQUIRE(references.value);
|
|
SceneComponentPayloadRef stale = references.value->front();
|
|
provider.invalidate();
|
|
auto diagnostics = registry.validatePayload(stale);
|
|
CHECK_EQ(static_cast<int>(diagnostics.status), static_cast<int>(EditorStatus::Conflict));
|
|
|
|
SelectionSnapshot mixed;
|
|
mixed.items = {{SelectionDomain::Scene, TargetId("scene"), StableId("hero.health"), "health"},
|
|
{SelectionDomain::Scene, TargetId("scene"), StableId("hero.audio"), "audio"}};
|
|
CHECK_EQ(static_cast<int>(registry.propertyProvider(mixed).status),
|
|
static_cast<int>(EditorStatus::Rejected));
|
|
const auto unregisterResult = registry.unregisterProvider(&provider);
|
|
REQUIRE_EQ(static_cast<int>(unregisterResult),
|
|
static_cast<int>(SceneComponentChange::Changed));
|
|
auto selection = makeSceneComponentSelection("scene", *references.value);
|
|
REQUIRE(selection.value);
|
|
CHECK_EQ(static_cast<int>(registry.propertyProvider(*selection.value).status),
|
|
static_cast<int>(EditorStatus::Unsupported));
|
|
}
|
|
|
|
TEST_CASE("editor.scene.component_bindings_reuse_audio_physics_and_material_targets") {
|
|
AudioSourceTarget audio("hero-audio");
|
|
PhysicsColliderTarget collider("hero-collider", 3);
|
|
MaterialDocumentTarget material("hero-material");
|
|
SceneComponentPropertyBindings audioBindings("audio.source");
|
|
SceneComponentPropertyBindings physicsBindings("physics.collider3d");
|
|
SceneComponentPropertyBindings materialBindings("graphics.material");
|
|
|
|
const TargetId scene("scene");
|
|
const ObjectId hero("hero");
|
|
REQUIRE(audioBindings.bind(
|
|
{scene, hero, StableId("hero.audio"), "audio.source", 1, 0},
|
|
{SelectionDomain::Scene, TargetId(audio.targetId()), StableId("source"), "audio.source"},
|
|
&audio, &audio, [&] { return audio.validate(); }).isAccepted());
|
|
REQUIRE(physicsBindings.bind(
|
|
{scene, hero, StableId("hero.collider"), "physics.collider3d", 2, 0},
|
|
{SelectionDomain::Scene, TargetId(collider.targetId()), StableId("collider"),
|
|
"physics.collider3d"},
|
|
&collider, &collider, [&] { return collider.validate(); }).isAccepted());
|
|
REQUIRE(materialBindings.bind(
|
|
{scene, hero, StableId("hero.material"), "graphics.material", 3, 0},
|
|
{SelectionDomain::Scene, TargetId(material.targetId()), StableId("material"),
|
|
"graphics.material"},
|
|
&material, &material, [&] { return material.validate(); }).isAccepted());
|
|
|
|
SceneComponentPayloadRegistry registry;
|
|
REQUIRE(registry.registerProvider(&audioBindings).isAccepted());
|
|
REQUIRE(registry.registerProvider(&physicsBindings).isAccepted());
|
|
REQUIRE(registry.registerProvider(&materialBindings).isAccepted());
|
|
auto all = registry.componentPayloads(scene, hero);
|
|
REQUIRE(all.value);
|
|
REQUIRE_EQ(all.value->size(), size_t{3});
|
|
CHECK_EQ(all.value->at(0).type, std::string("audio.source"));
|
|
CHECK_EQ(all.value->at(1).type, std::string("graphics.material"));
|
|
CHECK_EQ(all.value->at(2).type, std::string("physics.collider3d"));
|
|
|
|
const auto audioRef = all.value->at(0);
|
|
auto selection = makeSceneComponentSelection("inspector", {audioRef});
|
|
REQUIRE(selection.value);
|
|
auto properties = registry.propertyProvider(*selection.value);
|
|
auto operationTarget = registry.operationTarget(*selection.value);
|
|
REQUIRE(properties.value);
|
|
REQUIRE(operationTarget.value);
|
|
CHECK_EQ((*properties.value)->schema(*selection.value).typeId, std::string("audio.source"));
|
|
auto setVolume = (*properties.value)->makeSet(
|
|
*selection.value, PropertyPath("play.volume"), 0.25, PropertySetMode::Absolute);
|
|
REQUIRE(setVolume.value);
|
|
LocalWorldAuthority authority(*operationTarget.value);
|
|
LocalTransactionBackend transactions(&authority);
|
|
REQUIRE(commit(**operationTarget.value, transactions, *setVolume.value,
|
|
(*operationTarget.value)->revision(), "component.audio.volume").isAccepted());
|
|
CHECK_EQ(*audio.read({"module", {{SelectionDomain::Scene, TargetId(audio.targetId()),
|
|
StableId("source"), "audio.source"}}, {}, 0},
|
|
PropertyPath("play.volume")).value.getIf<double>(), 0.25);
|
|
CHECK_EQ(static_cast<int>(registry.validatePayload(audioRef).status),
|
|
static_cast<int>(EditorStatus::Conflict));
|
|
REQUIRE(transactions.undo().isAccepted());
|
|
CHECK_EQ(*audio.read({"module", {{SelectionDomain::Scene, TargetId(audio.targetId()),
|
|
StableId("source"), "audio.source"}}, {}, 0},
|
|
PropertyPath("play.volume")).value.getIf<double>(), 1.0);
|
|
|
|
auto colliderSelection = makeSceneComponentSelection("inspector", {all.value->at(2)});
|
|
REQUIRE(colliderSelection.value);
|
|
CHECK(registry.propertyProvider(*colliderSelection.value).value.value()->schema(
|
|
*colliderSelection.value).find(PropertyPath("material.friction")) != nullptr);
|
|
auto materialSelection = makeSceneComponentSelection("inspector", {all.value->at(1)});
|
|
REQUIRE(materialSelection.value);
|
|
CHECK(registry.propertyProvider(*materialSelection.value).value.value()->schema(
|
|
*materialSelection.value).find(PropertyPath("shading.roughness")) != nullptr);
|
|
}
|
|
|
|
TEST_CASE("editor.scene.audio_component_publication_is_staged_live_and_reversible") {
|
|
RecordingAudioSink sink;
|
|
AudioSourcePublishingTarget live("live-audio", &sink);
|
|
SceneComponentPropertyBindings bindings("audio.source");
|
|
REQUIRE(bindings.bind(
|
|
{TargetId("scene"), ObjectId("hero"), StableId("hero.audio"), "audio.source", 1, 0},
|
|
{SelectionDomain::Scene, TargetId(live.targetId()), StableId("source"), "audio.source"},
|
|
&live.authoringTarget(), &live,
|
|
[&] { return live.authoringTarget().validate(); }).isAccepted());
|
|
SceneComponentPayloadRegistry registry;
|
|
REQUIRE(registry.registerProvider(&bindings).isAccepted());
|
|
auto components = registry.componentPayloads(TargetId("scene"), ObjectId("hero"));
|
|
REQUIRE(components.value);
|
|
auto selection = makeSceneComponentSelection("inspector", *components.value);
|
|
REQUIRE(selection.value);
|
|
auto properties = registry.propertyProvider(*selection.value);
|
|
auto operationTarget = registry.operationTarget(*selection.value);
|
|
REQUIRE(properties.value);
|
|
REQUIRE(operationTarget.value);
|
|
|
|
auto change = (*properties.value)->makeSet(*selection.value, PropertyPath("play.volume"),
|
|
0.4, PropertySetMode::Absolute);
|
|
REQUIRE(change.value);
|
|
LocalWorldAuthority authority(*operationTarget.value);
|
|
LocalTransactionBackend transactions(&authority);
|
|
REQUIRE(commit(**operationTarget.value, transactions, *change.value,
|
|
(*operationTarget.value)->revision(), "audio.live.volume").isAccepted());
|
|
CHECK_EQ(sink.volume, 0.4);
|
|
CHECK_EQ(sink.revision, live.revision());
|
|
CHECK_EQ(sink.publishes, 1);
|
|
REQUIRE(transactions.undo().isAccepted());
|
|
CHECK_EQ(sink.volume, 1.0);
|
|
CHECK_EQ(sink.publishes, 2);
|
|
|
|
auto rejected = (*properties.value)->makeSet(*selection.value, PropertyPath("play.volume"),
|
|
0.2, PropertySetMode::Absolute);
|
|
REQUIRE(rejected.value);
|
|
sink.reject = true;
|
|
const Revision before = live.revision();
|
|
auto failed = commit(**operationTarget.value, transactions, *rejected.value, before,
|
|
"audio.live.rejected");
|
|
CHECK_EQ(static_cast<int>(failed.status), static_cast<int>(EditorStatus::Rejected));
|
|
CHECK_EQ(live.revision(), before);
|
|
CHECK_EQ(sink.volume, 1.0);
|
|
}
|
|
|
|
TEST_CASE("editor.scene.material_component_publication_is_staged_live_and_reversible") {
|
|
RecordingMaterialSink sink;
|
|
MaterialPublishingTarget live("live-material", &sink);
|
|
SceneComponentPropertyBindings bindings("graphics.material");
|
|
REQUIRE(bindings.bind(
|
|
{TargetId("scene"), ObjectId("hero"), StableId("hero.material"),
|
|
"graphics.material", 2, 0},
|
|
{SelectionDomain::Asset, TargetId(live.targetId()), StableId("material"),
|
|
"graphics.material"},
|
|
&live.authoringTarget(), &live,
|
|
[&] { return live.authoringTarget().validate(); }).isAccepted());
|
|
SceneComponentPayloadRegistry registry;
|
|
REQUIRE(registry.registerProvider(&bindings).isAccepted());
|
|
auto components = registry.componentPayloads(TargetId("scene"), ObjectId("hero"));
|
|
REQUIRE(components.value);
|
|
auto selection = makeSceneComponentSelection("inspector", *components.value);
|
|
REQUIRE(selection.value);
|
|
auto properties = registry.propertyProvider(*selection.value);
|
|
auto operationTarget = registry.operationTarget(*selection.value);
|
|
REQUIRE(properties.value);
|
|
REQUIRE(operationTarget.value);
|
|
|
|
auto change = (*properties.value)->makeSet(*selection.value,
|
|
PropertyPath("shading.roughness"), 0.8,
|
|
PropertySetMode::Absolute);
|
|
REQUIRE(change.value);
|
|
LocalWorldAuthority authority(*operationTarget.value);
|
|
LocalTransactionBackend transactions(&authority);
|
|
REQUIRE(commit(**operationTarget.value, transactions, *change.value,
|
|
(*operationTarget.value)->revision(), "material.live.roughness").isAccepted());
|
|
CHECK_EQ(sink.roughness, 0.8);
|
|
CHECK_EQ(sink.revision, live.revision());
|
|
REQUIRE(transactions.undo().isAccepted());
|
|
CHECK_EQ(sink.roughness, 0.45);
|
|
|
|
auto rejected = (*properties.value)->makeSet(*selection.value,
|
|
PropertyPath("shading.roughness"), 0.6,
|
|
PropertySetMode::Absolute);
|
|
REQUIRE(rejected.value);
|
|
sink.reject = true;
|
|
const Revision before = live.revision();
|
|
auto failed = commit(**operationTarget.value, transactions, *rejected.value, before,
|
|
"material.live.rejected");
|
|
CHECK_EQ(static_cast<int>(failed.status), static_cast<int>(EditorStatus::Rejected));
|
|
CHECK_EQ(live.revision(), before);
|
|
CHECK_EQ(sink.roughness, 0.45);
|
|
}
|