#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 "Core/PlaySessionController.h" #include #include #include #include #include #include #include #include #include #include #include namespace fs = std::filesystem; namespace XCEngine::Editor { namespace { bool DirectoryHasEntries(const fs::path& directoryPath) { std::error_code ec; if (!fs::exists(directoryPath, ec) || !fs::is_directory(directoryPath, ec)) { return false; } return fs::directory_iterator(directoryPath) != fs::directory_iterator(); } 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, CreatePrimitiveEntityAddsBuiltinMeshComponentsAndSupportsUndoRedo) { using XCEngine::Resources::BuiltinPrimitiveType; using XCEngine::Resources::GetBuiltinDefaultPrimitiveMaterialPath; using XCEngine::Resources::GetBuiltinPrimitiveMeshPath; auto* entity = Commands::CreatePrimitiveEntity(m_context, BuiltinPrimitiveType::Cube, nullptr); ASSERT_NE(entity, nullptr); EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), 1u); auto* meshFilter = entity->GetComponent<::XCEngine::Components::MeshFilterComponent>(); auto* meshRenderer = entity->GetComponent<::XCEngine::Components::MeshRendererComponent>(); ASSERT_NE(meshFilter, nullptr); ASSERT_NE(meshRenderer, nullptr); EXPECT_EQ(meshFilter->GetMeshPath(), GetBuiltinPrimitiveMeshPath(BuiltinPrimitiveType::Cube).CStr()); ASSERT_EQ(meshRenderer->GetMaterialCount(), 1u); EXPECT_EQ(meshRenderer->GetMaterialPath(0), GetBuiltinDefaultPrimitiveMaterialPath().CStr()); EXPECT_TRUE(m_context.GetUndoManager().CanUndo()); m_context.GetUndoManager().Undo(); EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), 0u); m_context.GetUndoManager().Redo(); EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), 1u); auto* restored = m_context.GetSceneManager().GetScene()->Find("Cube"); ASSERT_NE(restored, nullptr); auto* restoredMeshFilter = restored->GetComponent<::XCEngine::Components::MeshFilterComponent>(); auto* restoredMeshRenderer = restored->GetComponent<::XCEngine::Components::MeshRendererComponent>(); ASSERT_NE(restoredMeshFilter, nullptr); ASSERT_NE(restoredMeshRenderer, nullptr); EXPECT_EQ(restoredMeshFilter->GetMeshPath(), GetBuiltinPrimitiveMeshPath(BuiltinPrimitiveType::Cube).CStr()); ASSERT_EQ(restoredMeshRenderer->GetMaterialCount(), 1u); EXPECT_EQ(restoredMeshRenderer->GetMaterialPath(0), GetBuiltinDefaultPrimitiveMaterialPath().CStr()); } 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, MainMenuRouterRequestsPlayPauseResumeAndStepEvents) { int playStartRequestCount = 0; int playStopRequestCount = 0; int playPauseRequestCount = 0; int playResumeRequestCount = 0; int playStepRequestCount = 0; const uint64_t playStartSubscription = m_context.GetEventBus().Subscribe( [&](const PlayModeStartRequestedEvent&) { ++playStartRequestCount; }); const uint64_t playStopSubscription = m_context.GetEventBus().Subscribe( [&](const PlayModeStopRequestedEvent&) { ++playStopRequestCount; }); const uint64_t playPauseSubscription = m_context.GetEventBus().Subscribe( [&](const PlayModePauseRequestedEvent&) { ++playPauseRequestCount; }); const uint64_t playResumeSubscription = m_context.GetEventBus().Subscribe( [&](const PlayModeResumeRequestedEvent&) { ++playResumeRequestCount; }); const uint64_t playStepSubscription = m_context.GetEventBus().Subscribe( [&](const PlayModeStepRequestedEvent&) { ++playStepRequestCount; }); Actions::RequestTogglePlayMode(m_context); EXPECT_EQ(playStartRequestCount, 1); EXPECT_EQ(playStopRequestCount, 0); m_context.SetRuntimeMode(EditorRuntimeMode::Play); Actions::RequestTogglePauseMode(m_context); EXPECT_EQ(playPauseRequestCount, 1); EXPECT_EQ(playResumeRequestCount, 0); Actions::RequestStepPlayMode(m_context); EXPECT_EQ(playStepRequestCount, 0); m_context.SetRuntimeMode(EditorRuntimeMode::Paused); Actions::RequestTogglePauseMode(m_context); EXPECT_EQ(playResumeRequestCount, 1); Actions::RequestStepPlayMode(m_context); EXPECT_EQ(playStepRequestCount, 1); Actions::RequestTogglePlayMode(m_context); EXPECT_EQ(playStopRequestCount, 1); m_context.SetRuntimeMode(EditorRuntimeMode::Edit); Actions::RequestStepPlayMode(m_context); EXPECT_EQ(playStepRequestCount, 1); m_context.GetEventBus().Unsubscribe(playStartSubscription); m_context.GetEventBus().Unsubscribe(playStopSubscription); m_context.GetEventBus().Unsubscribe(playPauseSubscription); m_context.GetEventBus().Unsubscribe(playResumeSubscription); m_context.GetEventBus().Unsubscribe(playStepSubscription); } TEST_F(EditorActionRoutingTest, ProjectCommandsReportWhenScriptAssembliesCanBeRebuilt) { EXPECT_TRUE(Commands::CanRebuildScriptAssemblies(m_context)); m_context.SetRuntimeMode(EditorRuntimeMode::Play); EXPECT_FALSE(Commands::CanRebuildScriptAssemblies(m_context)); m_context.SetRuntimeMode(EditorRuntimeMode::Edit); m_context.SetProjectPath(std::string()); EXPECT_FALSE(Commands::CanRebuildScriptAssemblies(m_context)); } TEST_F(EditorActionRoutingTest, PlayModeAllowsRuntimeSceneUndoRedoButKeepsSceneDocumentCommandsBlocked) { const fs::path savedScenePath = m_projectRoot / "Assets" / "Scenes" / "PlayModeRuntimeEditing.xc"; ASSERT_TRUE(m_context.GetSceneManager().SaveSceneAs(savedScenePath.string())); ASSERT_FALSE(m_context.GetSceneManager().IsSceneDirty()); PlaySessionController controller; ASSERT_TRUE(controller.StartPlay(m_context)); EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Play); EXPECT_FALSE(m_context.GetSceneManager().IsSceneDocumentDirtyTrackingEnabled()); EXPECT_FALSE(Commands::NewScene(m_context, "Blocked During Play")); EXPECT_FALSE(Commands::SaveCurrentScene(m_context)); const size_t entityCountBeforeCreate = CountHierarchyEntities(m_context.GetSceneManager()); auto* runtimeEntity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Runtime Entity", "RuntimeOnly"); ASSERT_NE(runtimeEntity, nullptr); const uint64_t runtimeEntityId = runtimeEntity->GetID(); EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), entityCountBeforeCreate + 1); EXPECT_FALSE(m_context.GetSceneManager().IsSceneDirty()); const Actions::ActionBinding undoAction = Actions::MakeUndoAction(m_context); EXPECT_TRUE(undoAction.enabled); Actions::ExecuteUndo(m_context); EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), entityCountBeforeCreate); EXPECT_EQ(m_context.GetSceneManager().GetEntity(runtimeEntityId), nullptr); EXPECT_FALSE(m_context.GetSceneManager().IsSceneDirty()); const Actions::ActionBinding redoAction = Actions::MakeRedoAction(m_context); EXPECT_TRUE(redoAction.enabled); Actions::ExecuteRedo(m_context); EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), entityCountBeforeCreate + 1); EXPECT_FALSE(m_context.GetSceneManager().IsSceneDirty()); ASSERT_TRUE(controller.StopPlay(m_context)); EXPECT_EQ(m_context.GetRuntimeMode(), EditorRuntimeMode::Edit); EXPECT_TRUE(m_context.GetSceneManager().IsSceneDocumentDirtyTrackingEnabled()); EXPECT_FALSE(m_context.GetSceneManager().IsSceneDirty()); } 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, CreateTypedLightCommandsAssignExpectedNamesAndTypes) { auto* directionalLight = Commands::CreateDirectionalLightEntity(m_context); auto* pointLight = Commands::CreatePointLightEntity(m_context); auto* spotLight = Commands::CreateSpotLightEntity(m_context); ASSERT_NE(directionalLight, nullptr); ASSERT_NE(pointLight, nullptr); ASSERT_NE(spotLight, nullptr); EXPECT_EQ(directionalLight->GetName(), "Directional Light"); EXPECT_EQ(pointLight->GetName(), "Point Light"); EXPECT_EQ(spotLight->GetName(), "Spot Light"); auto* directionalComponent = directionalLight->GetComponent(); auto* pointComponent = pointLight->GetComponent(); auto* spotComponent = spotLight->GetComponent(); ASSERT_NE(directionalComponent, nullptr); ASSERT_NE(pointComponent, nullptr); ASSERT_NE(spotComponent, nullptr); EXPECT_EQ(directionalComponent->GetLightType(), Components::LightType::Directional); EXPECT_EQ(pointComponent->GetLightType(), Components::LightType::Point); EXPECT_EQ(spotComponent->GetLightType(), Components::LightType::Spot); } TEST_F(EditorActionRoutingTest, HierarchyItemContextRequestSelectsEntityAndStoresPopupTarget) { auto* entity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Entity", "ContextTarget"); ASSERT_NE(entity, nullptr); UI::TargetedPopupState<::XCEngine::Components::GameObject*> popupState; EXPECT_FALSE(popupState.HasTarget()); EXPECT_FALSE(popupState.HasPendingOpenRequest()); Actions::HandleHierarchyItemContextRequest(m_context, entity, popupState); EXPECT_EQ(m_context.GetSelectionManager().GetSelectedEntity(), entity->GetID()); ASSERT_TRUE(popupState.HasTarget()); EXPECT_EQ(popupState.TargetValue(), entity); EXPECT_TRUE(popupState.HasPendingOpenRequest()); } TEST_F(EditorActionRoutingTest, ProjectCommandsCreateFolderMoveAssetAndOpenFolderHelper) { const fs::path assetsDir = m_projectRoot / "Assets"; const fs::path sourceFilePath = assetsDir / "MoveMe.txt"; std::ofstream(sourceFilePath.string()) << "move source"; const AssetItemPtr folderItem = Commands::CreateFolder(m_context.GetProjectManager(), "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, ProjectCommandsCreateFolderUsesUniqueDefaultName) { const AssetItemPtr firstFolder = Commands::CreateFolder(m_context.GetProjectManager(), "New Folder"); const AssetItemPtr secondFolder = Commands::CreateFolder(m_context.GetProjectManager(), "New Folder"); ASSERT_NE(firstFolder, nullptr); ASSERT_NE(secondFolder, nullptr); EXPECT_NE(firstFolder->fullPath, secondFolder->fullPath); EXPECT_TRUE(fs::exists(m_projectRoot / "Assets" / "New Folder")); EXPECT_TRUE(fs::exists(m_projectRoot / "Assets" / "New Folder 1")); } TEST_F(EditorActionRoutingTest, ProjectCommandsRenameAssetUpdatesSelectionAndPreservesFileExtension) { const fs::path assetsDir = m_projectRoot / "Assets"; const fs::path originalPath = assetsDir / "RenameMe.txt"; std::ofstream(originalPath.string()) << "rename target"; m_context.GetProjectManager().RefreshCurrentFolder(); const AssetItemPtr item = FindCurrentItemByName("RenameMe.txt"); ASSERT_NE(item, nullptr); m_context.GetProjectManager().SetSelectedItem(item); EXPECT_TRUE(Commands::RenameAsset(m_context.GetProjectManager(), item, "RenamedAsset")); EXPECT_FALSE(fs::exists(originalPath)); EXPECT_TRUE(fs::exists(assetsDir / "RenamedAsset.txt")); const AssetItemPtr renamedItem = FindCurrentItemByName("RenamedAsset.txt"); ASSERT_NE(renamedItem, nullptr); EXPECT_EQ(m_context.GetProjectManager().GetSelectedItemPath(), renamedItem->fullPath); } TEST_F(EditorActionRoutingTest, ProjectItemContextRequestSelectsAssetAndStoresPopupTarget) { const fs::path assetsDir = m_projectRoot / "Assets"; const fs::path filePath = assetsDir / "ContextAsset.txt"; std::ofstream(filePath.string()) << "context asset"; m_context.GetProjectManager().RefreshCurrentFolder(); const AssetItemPtr item = FindCurrentItemByName("ContextAsset.txt"); ASSERT_NE(item, nullptr); UI::TargetedPopupState popupState; EXPECT_FALSE(popupState.HasTarget()); EXPECT_FALSE(popupState.HasPendingOpenRequest()); Actions::HandleProjectItemContextRequest(m_context.GetProjectManager(), item, popupState); EXPECT_EQ(m_context.GetProjectManager().GetSelectedItemPath(), item->fullPath); ASSERT_TRUE(popupState.HasTarget()); EXPECT_EQ(popupState.TargetValue(), item); EXPECT_TRUE(popupState.HasPendingOpenRequest()); } 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)); } TEST_F(EditorActionRoutingTest, ProjectSelectionSurvivesRefreshWhenItemOrderChanges) { const fs::path assetsDir = m_projectRoot / "Assets"; const fs::path selectedPath = assetsDir / "Selected.txt"; const fs::path earlierPath = assetsDir / "Earlier.txt"; std::ofstream(selectedPath.string()) << "selected"; m_context.GetProjectManager().RefreshCurrentFolder(); const int selectedIndex = FindCurrentItemIndexByName("Selected.txt"); ASSERT_GE(selectedIndex, 0); m_context.GetProjectManager().SetSelectedIndex(selectedIndex); AssetItemPtr selectedItem = Actions::GetSelectedAssetItem(m_context); ASSERT_NE(selectedItem, nullptr); EXPECT_EQ(selectedItem->name, "Selected.txt"); std::ofstream(earlierPath.string()) << "earlier"; m_context.GetProjectManager().RefreshCurrentFolder(); selectedItem = Actions::GetSelectedAssetItem(m_context); ASSERT_NE(selectedItem, nullptr); EXPECT_EQ(selectedItem->name, "Selected.txt"); EXPECT_EQ( m_context.GetProjectManager().GetSelectedIndex(), FindCurrentItemIndexByName("Selected.txt")); } TEST_F(EditorActionRoutingTest, ProjectCommandsExposeAssetCacheMaintenanceActions) { using ::XCEngine::Resources::ResourceManager; const fs::path materialPath = m_projectRoot / "Assets" / "ToolMaterial.mat"; std::ofstream(materialPath.string()) << "{\n \"renderQueue\": \"geometry\"\n}\n"; m_context.GetProjectManager().RefreshCurrentFolder(); ResourceManager& resourceManager = ResourceManager::Get(); resourceManager.Initialize(); resourceManager.SetResourceRoot(m_projectRoot.string().c_str()); const AssetItemPtr materialItem = FindCurrentItemByName("ToolMaterial.mat"); ASSERT_NE(materialItem, nullptr); m_context.GetProjectManager().SetSelectedItem(materialItem); EXPECT_TRUE(Commands::CanReimportSelectedAsset(m_context)); EXPECT_TRUE(Commands::CanReimportAllAssets(m_context)); EXPECT_TRUE(Commands::CanClearLibrary(m_context)); m_context.SetRuntimeMode(EditorRuntimeMode::Play); EXPECT_FALSE(Commands::CanReimportSelectedAsset(m_context)); EXPECT_FALSE(Commands::CanReimportAllAssets(m_context)); EXPECT_FALSE(Commands::CanClearLibrary(m_context)); m_context.SetRuntimeMode(EditorRuntimeMode::Edit); resourceManager.SetResourceRoot(""); resourceManager.Shutdown(); } TEST_F(EditorActionRoutingTest, ProjectCommandsReimportSelectedAssetAndClearLibraryDriveAssetCache) { using ::XCEngine::Resources::ResourceManager; const fs::path materialPath = m_projectRoot / "Assets" / "ToolMaterial.mat"; std::ofstream(materialPath.string()) << "{\n \"renderQueue\": \"geometry\"\n}\n"; m_context.GetProjectManager().RefreshCurrentFolder(); ResourceManager& resourceManager = ResourceManager::Get(); resourceManager.Initialize(); resourceManager.SetResourceRoot(m_projectRoot.string().c_str()); const AssetItemPtr materialItem = FindCurrentItemByName("ToolMaterial.mat"); ASSERT_NE(materialItem, nullptr); m_context.GetProjectManager().SetSelectedItem(materialItem); const fs::path libraryRoot(resourceManager.GetProjectLibraryRoot().CStr()); EXPECT_TRUE(Commands::ReimportSelectedAsset(m_context)); EXPECT_TRUE(DirectoryHasEntries(libraryRoot / "Artifacts")); EXPECT_TRUE(Commands::ClearLibrary(m_context)); EXPECT_FALSE(DirectoryHasEntries(libraryRoot / "Artifacts")); EXPECT_TRUE(Commands::ReimportAllAssets(m_context)); EXPECT_TRUE(DirectoryHasEntries(libraryRoot / "Artifacts")); resourceManager.SetResourceRoot(""); resourceManager.Shutdown(); } TEST_F(EditorActionRoutingTest, ProjectCommandsRejectMovingFolderIntoItsDescendant) { const fs::path assetsDir = m_projectRoot / "Assets"; const fs::path parentPath = assetsDir / "Parent"; const fs::path childPath = parentPath / "Child"; fs::create_directories(childPath); m_context.GetProjectManager().RefreshCurrentFolder(); const AssetItemPtr parentFolder = FindCurrentItemByName("Parent"); ASSERT_NE(parentFolder, nullptr); ASSERT_TRUE(parentFolder->isFolder); m_context.GetProjectManager().NavigateToFolder(parentFolder); const AssetItemPtr childFolder = FindCurrentItemByName("Child"); ASSERT_NE(childFolder, nullptr); ASSERT_TRUE(childFolder->isFolder); EXPECT_FALSE(Commands::MoveAssetToFolder(m_context.GetProjectManager(), parentFolder->fullPath, childFolder)); EXPECT_TRUE(fs::exists(parentPath)); EXPECT_TRUE(fs::exists(childPath)); } } // namespace } // namespace XCEngine::Editor