#include "audio/editor/EditorAudioTarget.h" #include "editor/EditorAuthority.h" #include "editor/EditorTransactionService.h" #include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include using namespace eve::editor; namespace { SelectionSnapshot sourceSelection(const AudioSourceTarget& target) { SelectionSnapshot selection; selection.channel = "scene"; SelectionItem item; item.domain = SelectionDomain::Scene; item.target = TargetId(target.targetId()); item.item = StableId(target.targetId().value()); item.type = "audio.source"; selection.items.push_back(item); selection.primary = item; return selection; } template EditorResult commit(Target& target, LocalTransactionBackend& transactions, const DomainOperation& operation, const char* id) { TransactionSpec specification; specification.id = TransactionId(id); specification.label = "Edit audio"; specification.target = TargetId(target.targetId()); specification.baseRevision = target.revision(); auto begun = transactions.begin(std::move(specification)); if (!begun.ok()) return eve::editing::failed(begun.code(), RuleId("test.audio.begin"), "Could not begin audio transaction"); auto appended = transactions.append(operation); if (!appended.ok()) { [[maybe_unused]] const auto rolledBack = transactions.rollback(); return eve::editing::failed(appended.code(), RuleId("test.audio.append"), "Could not append audio operation"); } return transactions.commit(); } } // namespace TEST_CASE("editor.audio.source_schema_covers_playback_spatial_attenuation_and_routing") { AudioSourceTarget target("ambient-river"); const auto schema = target.schema(sourceSelection(target)); CHECK_EQ(schema.typeId, std::string("audio.source")); CHECK(schema.find(PropertyPath("clip.asset")) != nullptr); CHECK(schema.find(PropertyPath("play.loop-start")) != nullptr); CHECK(schema.find(PropertyPath("spatial.position")) != nullptr); CHECK(schema.find(PropertyPath("spatial.maximum-distance")) != nullptr); CHECK(schema.find(PropertyPath("mixer.bus")) != nullptr); CHECK_EQ(target.validate().size(), static_cast(1)); } TEST_CASE("editor.audio.source_edits_are_reversible_and_cross_validated") { AudioSourceTarget target("ambient-river"); LocalWorldAuthority authority(&target); LocalTransactionBackend transactions(&authority); const SelectionSnapshot selection = sourceSelection(target); for (const auto& [path, value, id] : { std::tuple{PropertyPath("clip.asset"), EditorValue("asset://audio/river.ogg"), "audio.clip"}, {PropertyPath("play.loop"), EditorValue(true), "audio.loop"}, {PropertyPath("play.loop-start"), EditorValue(10.0), "audio.loop-start"}, {PropertyPath("play.loop-end"), EditorValue(5.0), "audio.loop-end"}}) { auto operation = target.makeSet(selection, path, value, PropertySetMode::Absolute); REQUIRE(operation.ok()); REQUIRE(commit(target, transactions, operation.value(), id).ok()); } CHECK_EQ(target.validate().size(), static_cast(1)); REQUIRE(transactions.undo().ok()); CHECK(target.validate().empty()); REQUIRE(transactions.redo().ok()); CHECK_EQ(target.validate().size(), static_cast(1)); AudioSourceTarget restored("restored"); REQUIRE(restored.loadSnapshot(target.snapshotValue()).ok()); CHECK(restored.read(sourceSelection(restored), PropertyPath("clip.asset")).value == EditorValue("asset://audio/river.ogg")); EditorValue unknownVersion = target.snapshotValue(); auto* unknownRoot = unknownVersion.getIf(); REQUIRE(unknownRoot != nullptr); unknownRoot->at("schemaVersion") = int64_t{2}; const Revision revisionBeforeRejectedMigration = restored.revision(); CHECK_EQ(static_cast(restored.loadSnapshot(unknownVersion).code()), static_cast(EditorStatus::Rejected)); CHECK_EQ(restored.revision(), revisionBeforeRejectedMigration); CHECK(restored.read(sourceSelection(restored), PropertyPath("clip.asset")).value == EditorValue("asset://audio/river.ogg")); } TEST_CASE("editor.audio.mixer_bus_hierarchy_is_cycle_safe_and_reversible") { AudioMixerTarget mixer("main-mixer"); LocalWorldAuthority authority(&mixer); LocalTransactionBackend transactions(&authority); auto music = mixer.makeCreate({ObjectId("music"), ObjectId("master"), "Music", 0.8}); REQUIRE(music.ok()); REQUIRE(commit(mixer, transactions, music.value(), "audio.bus.music").ok()); auto combat = mixer.makeCreate({ObjectId("combat"), ObjectId("music"), "Combat", 1.0}); REQUIRE(combat.ok()); REQUIRE(commit(mixer, transactions, combat.value(), "audio.bus.combat").ok()); CHECK_EQ(mixer.children(ObjectId("music")).size(), static_cast(1)); CHECK_EQ(static_cast(mixer.makeDelete(ObjectId("music")).code()), static_cast(EditorStatus::Rejected)); AudioBusSnapshot changed = mixer.bus(ObjectId("music")).value(); changed.parent = ObjectId("combat"); CHECK_EQ(static_cast(mixer.makeReplace(changed).code()), static_cast(EditorStatus::Rejected)); changed = mixer.bus(ObjectId("combat")).value(); changed.volume = 0.5; changed.mute = true; auto replace = mixer.makeReplace(changed); REQUIRE(replace.ok()); REQUIRE(commit(mixer, transactions, replace.value(), "audio.bus.settings").ok()); CHECK_EQ(mixer.bus(ObjectId("combat")).value().volume, 0.5); CHECK(mixer.bus(ObjectId("combat")).value().mute); REQUIRE(transactions.undo().ok()); CHECK_EQ(mixer.bus(ObjectId("combat")).value().volume, 1.0); CHECK(!mixer.bus(ObjectId("combat")).value().mute); CHECK_EQ(static_cast(mixer.snapshotValue().type()), static_cast(EditorValue::Type::Object)); AudioMixerTarget restored("restored-mixer"); REQUIRE(restored.loadSnapshot(mixer.snapshotValue()).ok()); CHECK_EQ(restored.children(ObjectId("music")).size(), static_cast(1)); }