#include #include "Actions/EditActionRouter.h" #include "Actions/HierarchyActionRouter.h" #include "Actions/MainMenuActionRouter.h" #include "Actions/ProjectActionRouter.h" #include "Commands/EntityCommands.h" #include "Commands/SceneCommands.h" #include "Core/EditorContext.h" #include #include #include #include #include #include namespace fs = std::filesystem; namespace XCEngine::Editor { namespace { class EditorActionRoutingTest : public ::testing::Test { protected: void SetUp() override { const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); m_projectRoot = fs::temp_directory_path() / ("xc_editor_tests_" + std::to_string(stamp)); fs::create_directories(m_projectRoot); m_context.SetProjectPath(m_projectRoot.string()); m_context.GetProjectManager().Initialize(m_projectRoot.string()); m_context.GetSceneManager().NewScene("Editor Test Scene"); } void TearDown() override { std::error_code ec; fs::remove_all(m_projectRoot, ec); } AssetItemPtr FindCurrentItemByName(const std::string& name) { for (auto& item : m_context.GetProjectManager().GetCurrentItems()) { if (item && item->name == name) { return item; } } return nullptr; } int FindCurrentItemIndexByName(const std::string& name) { auto& items = m_context.GetProjectManager().GetCurrentItems(); for (int i = 0; i < static_cast(items.size()); ++i) { if (items[i] && items[i]->name == name) { return i; } } return -1; } static void ExpectNear(const Math::Vector3& actual, const Math::Vector3& expected, float epsilon = 1e-4f) { EXPECT_NEAR(actual.x, expected.x, epsilon); EXPECT_NEAR(actual.y, expected.y, epsilon); EXPECT_NEAR(actual.z, expected.z, epsilon); } static size_t CountHierarchyEntities(const ISceneManager& sceneManager) { auto countChildren = [](const ::XCEngine::Components::GameObject* gameObject, const auto& self) -> size_t { if (!gameObject) { return 0; } size_t count = 1; for (size_t i = 0; i < gameObject->GetChildCount(); ++i) { count += self(gameObject->GetChild(i), self); } return count; }; size_t total = 0; for (auto* root : sceneManager.GetRootEntities()) { total += countChildren(root, countChildren); } return total; } EditorContext m_context; fs::path m_projectRoot; }; TEST_F(EditorActionRoutingTest, HierarchyRouteExecutesCopyPasteDuplicateDeleteAndRename) { auto* entity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Entity", "Original"); ASSERT_NE(entity, nullptr); m_context.SetActiveActionRoute(EditorActionRoute::Hierarchy); m_context.GetSelectionManager().SetSelectedEntity(entity->GetID()); const Actions::EditActionTarget target = Actions::ResolveEditActionTarget(m_context); ASSERT_EQ(target.route, EditorActionRoute::Hierarchy); ASSERT_EQ(target.selectedGameObject, entity); uint64_t renameRequestedId = 0; const uint64_t renameSubscription = m_context.GetEventBus().Subscribe( [&](const EntityRenameRequestedEvent& event) { renameRequestedId = event.entityId; }); EXPECT_TRUE(Actions::ExecuteRenameSelection(m_context, target)); EXPECT_EQ(renameRequestedId, entity->GetID()); m_context.GetEventBus().Unsubscribe(renameSubscription); const size_t entityCountBeforePaste = CountHierarchyEntities(m_context.GetSceneManager()); EXPECT_TRUE(Actions::ExecuteCopySelection(m_context, target)); EXPECT_TRUE(m_context.GetSceneManager().HasClipboardData()); EXPECT_TRUE(Actions::ExecutePasteSelection(m_context, target)); EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), entityCountBeforePaste + 1); const uint64_t pastedEntityId = m_context.GetSelectionManager().GetSelectedEntity(); EXPECT_NE(pastedEntityId, 0u); EXPECT_NE(pastedEntityId, entity->GetID()); ASSERT_NE(m_context.GetSceneManager().GetEntity(pastedEntityId), nullptr); EXPECT_EQ(m_context.GetSceneManager().GetEntity(pastedEntityId)->GetParent(), entity); const Actions::EditActionTarget pastedTarget = Actions::ResolveEditActionTarget(m_context); ASSERT_NE(pastedTarget.selectedGameObject, nullptr); const size_t entityCountBeforeDuplicate = CountHierarchyEntities(m_context.GetSceneManager()); EXPECT_TRUE(Actions::ExecuteDuplicateSelection(m_context, pastedTarget)); EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), entityCountBeforeDuplicate + 1); const uint64_t duplicatedEntityId = m_context.GetSelectionManager().GetSelectedEntity(); const Actions::EditActionTarget duplicatedTarget = Actions::ResolveEditActionTarget(m_context); ASSERT_NE(duplicatedTarget.selectedGameObject, nullptr); EXPECT_EQ(duplicatedTarget.selectedGameObject->GetParent(), entity); EXPECT_TRUE(Actions::ExecuteDeleteSelection(m_context, duplicatedTarget)); EXPECT_EQ(m_context.GetSceneManager().GetEntity(duplicatedEntityId), nullptr); EXPECT_FALSE(m_context.GetSelectionManager().IsSelected(duplicatedEntityId)); } TEST_F(EditorActionRoutingTest, ProjectRouteExecutesOpenBackAndDelete) { const fs::path assetsDir = m_projectRoot / "Assets"; const fs::path folderPath = assetsDir / "RouteFolder"; const fs::path filePath = assetsDir / "DeleteMe.txt"; fs::create_directories(folderPath); std::ofstream(filePath.string()) << "temporary"; m_context.GetProjectManager().RefreshCurrentFolder(); const int folderIndex = FindCurrentItemIndexByName("RouteFolder"); ASSERT_GE(folderIndex, 0); m_context.SetActiveActionRoute(EditorActionRoute::Project); m_context.GetProjectManager().SetSelectedIndex(folderIndex); const Actions::EditActionTarget folderTarget = Actions::ResolveEditActionTarget(m_context); ASSERT_EQ(folderTarget.route, EditorActionRoute::Project); ASSERT_NE(folderTarget.selectedAssetItem, nullptr); EXPECT_EQ(folderTarget.selectedAssetItem->name, "RouteFolder"); EXPECT_TRUE(Actions::ExecuteOpenSelection(m_context, folderTarget)); EXPECT_EQ(m_context.GetProjectManager().GetCurrentPath(), "Assets/RouteFolder"); const Actions::EditActionTarget backTarget = Actions::ResolveEditActionTarget(m_context); EXPECT_TRUE(Actions::ExecuteNavigateBackSelection(m_context, backTarget)); EXPECT_EQ(m_context.GetProjectManager().GetCurrentPath(), "Assets"); m_context.GetProjectManager().RefreshCurrentFolder(); const int fileIndex = FindCurrentItemIndexByName("DeleteMe.txt"); ASSERT_GE(fileIndex, 0); m_context.GetProjectManager().SetSelectedIndex(fileIndex); const Actions::EditActionTarget deleteTarget = Actions::ResolveEditActionTarget(m_context); ASSERT_NE(deleteTarget.selectedAssetItem, nullptr); EXPECT_TRUE(Actions::ExecuteDeleteSelection(m_context, deleteTarget)); EXPECT_FALSE(fs::exists(filePath)); } TEST_F(EditorActionRoutingTest, LoadSceneResetsSelectionAndUndoAfterFallbackSave) { auto* savedEntity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Saved", "SavedEntity"); ASSERT_NE(savedEntity, nullptr); ASSERT_TRUE(m_context.GetSelectionManager().HasSelection()); ASSERT_TRUE(m_context.GetUndoManager().CanUndo()); const fs::path savedScenePath = m_projectRoot / "Assets" / "Scenes" / "RegressionScene.xc"; EXPECT_TRUE(Commands::SaveDirtySceneWithFallback(m_context, savedScenePath.string())); EXPECT_TRUE(fs::exists(savedScenePath)); EXPECT_FALSE(m_context.GetSceneManager().IsSceneDirty()); auto* transientEntity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Transient", "TransientEntity"); ASSERT_NE(transientEntity, nullptr); ASSERT_TRUE(m_context.GetSelectionManager().HasSelection()); ASSERT_TRUE(m_context.GetUndoManager().CanUndo()); EXPECT_TRUE(Commands::LoadScene(m_context, savedScenePath.string(), false)); EXPECT_FALSE(m_context.GetSelectionManager().HasSelection()); EXPECT_FALSE(m_context.GetUndoManager().CanUndo()); ASSERT_EQ(m_context.GetSceneManager().GetRootEntities().size(), 1u); EXPECT_EQ(m_context.GetSceneManager().GetRootEntities()[0]->GetName(), "SavedEntity"); } TEST_F(EditorActionRoutingTest, NewSceneResetsSelectionAndUndoForCleanScene) { auto* entity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Entity", "BeforeNewScene"); ASSERT_NE(entity, nullptr); ASSERT_TRUE(m_context.GetSelectionManager().HasSelection()); ASSERT_TRUE(m_context.GetUndoManager().CanUndo()); const fs::path savedScenePath = m_projectRoot / "Assets" / "Scenes" / "BeforeNewScene.xc"; ASSERT_TRUE(m_context.GetSceneManager().SaveSceneAs(savedScenePath.string())); ASSERT_FALSE(m_context.GetSceneManager().IsSceneDirty()); EXPECT_TRUE(Commands::NewScene(m_context, "Fresh Scene")); EXPECT_TRUE(m_context.GetSceneManager().HasActiveScene()); EXPECT_TRUE(m_context.GetSceneManager().IsSceneDirty()); EXPECT_EQ(m_context.GetSceneManager().GetCurrentSceneName(), "Fresh Scene"); EXPECT_TRUE(m_context.GetSceneManager().GetCurrentScenePath().empty()); EXPECT_TRUE(m_context.GetSceneManager().GetRootEntities().empty()); EXPECT_FALSE(m_context.GetSelectionManager().HasSelection()); EXPECT_FALSE(m_context.GetUndoManager().CanUndo()); } TEST_F(EditorActionRoutingTest, SaveDirtySceneWithFallbackDoesNothingWhenSceneIsAlreadyClean) { const fs::path savedScenePath = m_projectRoot / "Assets" / "Scenes" / "CleanScene.xc"; ASSERT_TRUE(m_context.GetSceneManager().SaveSceneAs(savedScenePath.string())); ASSERT_FALSE(m_context.GetSceneManager().IsSceneDirty()); const fs::path unusedFallbackPath = m_projectRoot / "Assets" / "Scenes" / "UnusedFallback.xc"; EXPECT_TRUE(Commands::SaveDirtySceneWithFallback(m_context, unusedFallbackPath.string())); EXPECT_TRUE(fs::exists(savedScenePath)); EXPECT_FALSE(fs::exists(unusedFallbackPath)); EXPECT_EQ(fs::path(m_context.GetSceneManager().GetCurrentScenePath()), savedScenePath); } TEST_F(EditorActionRoutingTest, ReparentPreserveWorldTransformKeepsWorldPose) { auto* parentA = Commands::CreateEmptyEntity(m_context, nullptr, "Create Parent A", "ParentA"); auto* child = Commands::CreateEmptyEntity(m_context, parentA, "Create Child", "Child"); auto* parentB = Commands::CreateEmptyEntity(m_context, nullptr, "Create Parent B", "ParentB"); ASSERT_NE(parentA, nullptr); ASSERT_NE(child, nullptr); ASSERT_NE(parentB, nullptr); parentA->GetTransform()->SetPosition(Math::Vector3(5.0f, 1.0f, -2.0f)); parentB->GetTransform()->SetPosition(Math::Vector3(-4.0f, 3.0f, 8.0f)); child->GetTransform()->SetLocalPosition(Math::Vector3(2.0f, 3.0f, 4.0f)); child->GetTransform()->SetLocalRotation(Math::Quaternion::FromEulerAngles(Math::Vector3(0.25f, 0.5f, 0.75f))); child->GetTransform()->SetLocalScale(Math::Vector3(1.5f, 2.0f, 0.5f)); const Math::Vector3 worldPositionBefore = child->GetTransform()->GetPosition(); const Math::Vector3 worldScaleBefore = child->GetTransform()->GetScale(); EXPECT_TRUE(Commands::CanReparentEntity(child, parentB)); EXPECT_FALSE(Commands::CanReparentEntity(parentA, child)); EXPECT_TRUE(Commands::ReparentEntityPreserveWorldTransform(m_context, child, parentB->GetID())); EXPECT_EQ(child->GetParent(), parentB); ExpectNear(child->GetTransform()->GetPosition(), worldPositionBefore); ExpectNear(child->GetTransform()->GetScale(), worldScaleBefore); } TEST_F(EditorActionRoutingTest, MainMenuRouterRequestsExitResetAndAboutPopup) { int exitRequestCount = 0; int resetLayoutCount = 0; const uint64_t exitSubscription = m_context.GetEventBus().Subscribe( [&](const EditorExitRequestedEvent&) { ++exitRequestCount; }); const uint64_t resetSubscription = m_context.GetEventBus().Subscribe( [&](const DockLayoutResetRequestedEvent&) { ++resetLayoutCount; }); UI::DeferredPopupState aboutPopup; EXPECT_FALSE(aboutPopup.HasPendingOpenRequest()); Actions::RequestAboutPopup(aboutPopup); Actions::RequestDockLayoutReset(m_context); Actions::RequestEditorExit(m_context); EXPECT_TRUE(aboutPopup.HasPendingOpenRequest()); EXPECT_EQ(resetLayoutCount, 1); EXPECT_EQ(exitRequestCount, 1); m_context.GetEventBus().Unsubscribe(exitSubscription); m_context.GetEventBus().Unsubscribe(resetSubscription); } TEST_F(EditorActionRoutingTest, HierarchyRouterRenameHelpersPublishAndCommit) { auto* entity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Entity", "BeforeRename"); ASSERT_NE(entity, nullptr); uint64_t renameRequestedId = 0; const uint64_t renameSubscription = m_context.GetEventBus().Subscribe( [&](const EntityRenameRequestedEvent& event) { renameRequestedId = event.entityId; }); Actions::RequestEntityRename(m_context, entity); EXPECT_EQ(renameRequestedId, entity->GetID()); EXPECT_TRUE(Actions::CommitEntityRename(m_context, entity->GetID(), "AfterRename")); ASSERT_NE(m_context.GetSceneManager().GetEntity(entity->GetID()), nullptr); EXPECT_EQ(m_context.GetSceneManager().GetEntity(entity->GetID())->GetName(), "AfterRename"); EXPECT_TRUE(m_context.GetUndoManager().CanUndo()); EXPECT_FALSE(Actions::CommitEntityRename(m_context, entity->GetID(), "")); EXPECT_FALSE(Actions::CommitEntityRename(m_context, 0, "Invalid")); m_context.GetEventBus().Unsubscribe(renameSubscription); } TEST_F(EditorActionRoutingTest, ProjectCommandsCreateFolderMoveAssetAndOpenFolderHelper) { const fs::path assetsDir = m_projectRoot / "Assets"; const fs::path sourceFilePath = assetsDir / "MoveMe.txt"; std::ofstream(sourceFilePath.string()) << "move source"; EXPECT_TRUE(Commands::CreateFolder(m_context.GetProjectManager(), "MovedFolder")); m_context.GetProjectManager().RefreshCurrentFolder(); const AssetItemPtr folderItem = FindCurrentItemByName("MovedFolder"); ASSERT_NE(folderItem, nullptr); ASSERT_TRUE(folderItem->isFolder); EXPECT_TRUE(Commands::MoveAssetToFolder(m_context.GetProjectManager(), sourceFilePath.string(), folderItem)); EXPECT_FALSE(fs::exists(sourceFilePath)); EXPECT_TRUE(fs::exists(assetsDir / "MovedFolder" / "MoveMe.txt")); EXPECT_TRUE(Actions::OpenProjectAsset(m_context, folderItem)); EXPECT_EQ(m_context.GetProjectManager().GetCurrentPath(), "Assets/MovedFolder"); } TEST_F(EditorActionRoutingTest, ProjectCommandsRejectInvalidMoveTargets) { const fs::path assetsDir = m_projectRoot / "Assets"; const fs::path sourceFilePath = assetsDir / "MoveSource.txt"; const fs::path targetFolderPath = assetsDir / "TargetFolder"; const fs::path plainFilePath = assetsDir / "PlainFile.txt"; std::ofstream(sourceFilePath.string()) << "move source"; std::ofstream(plainFilePath.string()) << "not a folder"; fs::create_directories(targetFolderPath); m_context.GetProjectManager().RefreshCurrentFolder(); const AssetItemPtr targetFolder = FindCurrentItemByName("TargetFolder"); const AssetItemPtr plainFile = FindCurrentItemByName("PlainFile.txt"); ASSERT_NE(targetFolder, nullptr); ASSERT_TRUE(targetFolder->isFolder); ASSERT_NE(plainFile, nullptr); ASSERT_FALSE(plainFile->isFolder); EXPECT_FALSE(Commands::MoveAssetToFolder(m_context.GetProjectManager(), "", targetFolder)); EXPECT_FALSE(Commands::MoveAssetToFolder(m_context.GetProjectManager(), sourceFilePath.string(), nullptr)); EXPECT_FALSE(Commands::MoveAssetToFolder(m_context.GetProjectManager(), sourceFilePath.string(), plainFile)); EXPECT_FALSE(Commands::MoveAssetToFolder(m_context.GetProjectManager(), targetFolder->fullPath, targetFolder)); EXPECT_TRUE(fs::exists(sourceFilePath)); } } // namespace } // namespace XCEngine::Editor