#include "Project/ProjectPanel.h" #include "Assets/EditorIconService.h" #include "SystemInteractionService.h" #include "Panels/EditorPanelIds.h" #include #include #include #include #include namespace XCEngine::UI::Editor::App { namespace { class FakeSystemInteractionHost final : public System::SystemInteractionService { public: bool CopyTextToClipboard(std::string_view text) override { lastClipboardText = std::string(text); ++clipboardCallCount; return clipboardResult; } bool RevealPathInFileBrowser( const std::filesystem::path& path, bool selectTarget) override { lastRevealPath = path; lastRevealSelectTarget = selectTarget; ++revealCallCount; return revealResult; } bool clipboardResult = true; bool revealResult = true; int clipboardCallCount = 0; int revealCallCount = 0; bool lastRevealSelectTarget = false; std::string lastClipboardText = {}; std::filesystem::path lastRevealPath = {}; }; class FakeIconService final : public EditorIconService { public: void Initialize( Rendering::Host::UiTextureHost&, Host::EditorHostResourceService&) override {} void Shutdown() override {} void BeginFrame() override {} const ::XCEngine::UI::UITextureHandle& Resolve(BuiltInIconKind) const override { return texture; } const ::XCEngine::UI::UITextureHandle* ResolveAssetPreview( const std::filesystem::path&, const std::filesystem::path&) override { return nullptr; } const std::string& GetLastError() const override { return error; } private: ::XCEngine::UI::UITextureHandle texture = {}; std::string error = {}; }; class TemporaryRepo final { public: TemporaryRepo() { const auto uniqueSuffix = std::chrono::steady_clock::now().time_since_epoch().count(); m_root = std::filesystem::temp_directory_path() / ("xcengine_project_panel_" + std::to_string(uniqueSuffix)); } ~TemporaryRepo() { std::error_code errorCode = {}; std::filesystem::remove_all(m_root, errorCode); } const std::filesystem::path& Root() const { return m_root; } bool CreateDirectory(const std::filesystem::path& relativePath) const { std::error_code errorCode = {}; std::filesystem::create_directories(m_root / relativePath, errorCode); return !errorCode; } bool WriteFile( const std::filesystem::path& relativePath, std::string_view contents = "test") const { const std::filesystem::path absolutePath = m_root / relativePath; std::error_code errorCode = {}; std::filesystem::create_directories(absolutePath.parent_path(), errorCode); if (errorCode) { return false; } std::ofstream stream(absolutePath, std::ios::binary); if (!stream.is_open()) { return false; } stream << contents; return stream.good(); } private: std::filesystem::path m_root = {}; }; UIEditorHostedPanelDispatchEntry MakeProjectDispatchEntry() { UIEditorHostedPanelDispatchEntry entry = {}; entry.panelId = std::string(kProjectPanelId); entry.presentationKind = UIEditorPanelPresentationKind::HostedContent; entry.mounted = true; entry.bounds = ::XCEngine::UI::UIRect(0.0f, 0.0f, 640.0f, 360.0f); entry.allowInteraction = true; entry.attached = true; entry.visible = true; entry.active = true; entry.focused = true; return entry; } ::XCEngine::UI::UIInputEvent MakePointerButtonDown( float x, float y, ::XCEngine::UI::UIPointerButton button) { ::XCEngine::UI::UIInputEvent event = {}; event.type = ::XCEngine::UI::UIInputEventType::PointerButtonDown; event.position = ::XCEngine::UI::UIPoint(x, y); event.pointerButton = button; return event; } TEST(ProjectPanelTests, CreateFolderCommandCreatesDirectoryAndQueuesRename) { TemporaryRepo repo = {}; EditorProjectRuntime runtime = {}; ASSERT_TRUE(runtime.Initialize(repo.Root() / "project")); ProjectPanel panel = {}; panel.SetProjectRuntime(&runtime); const UIEditorHostCommandEvaluationResult evaluation = panel.EvaluateAssetCommand("assets.create_folder"); EXPECT_TRUE(evaluation.executable); const UIEditorHostCommandDispatchResult dispatch = panel.DispatchAssetCommand("assets.create_folder"); EXPECT_TRUE(dispatch.commandExecuted); EXPECT_TRUE(std::filesystem::exists(repo.Root() / "project/Assets/New Folder")); const auto& events = panel.GetFrameEvents(); ASSERT_EQ(events.size(), 2u); EXPECT_EQ(events[0].kind, ProjectPanel::EventKind::AssetSelected); EXPECT_EQ(events[0].source, ProjectPanel::EventSource::Command); EXPECT_EQ(events[0].itemId, "Assets/New Folder"); EXPECT_EQ(events[1].kind, ProjectPanel::EventKind::RenameRequested); EXPECT_EQ(events[1].source, ProjectPanel::EventSource::Command); EXPECT_EQ(events[1].itemId, "Assets/New Folder"); } TEST(ProjectPanelTests, CreateMaterialCommandCreatesFileAndQueuesRename) { TemporaryRepo repo = {}; EditorProjectRuntime runtime = {}; ASSERT_TRUE(runtime.Initialize(repo.Root() / "project")); ProjectPanel panel = {}; panel.SetProjectRuntime(&runtime); const UIEditorHostCommandEvaluationResult evaluation = panel.EvaluateAssetCommand("assets.create_material"); EXPECT_TRUE(evaluation.executable); const UIEditorHostCommandDispatchResult dispatch = panel.DispatchAssetCommand("assets.create_material"); EXPECT_TRUE(dispatch.commandExecuted); EXPECT_TRUE(std::filesystem::exists(repo.Root() / "project/Assets/New Material.mat")); const auto& events = panel.GetFrameEvents(); ASSERT_EQ(events.size(), 2u); EXPECT_EQ(events[0].kind, ProjectPanel::EventKind::AssetSelected); EXPECT_EQ(events[0].itemId, "Assets/New Material.mat"); EXPECT_EQ(events[1].kind, ProjectPanel::EventKind::RenameRequested); EXPECT_EQ(events[1].itemId, "Assets/New Material.mat"); } TEST(ProjectPanelTests, BackgroundContextMenuCreateFolderUsesCurrentFolder) { TemporaryRepo repo = {}; EditorProjectRuntime runtime = {}; ASSERT_TRUE(runtime.Initialize(repo.Root() / "project")); ProjectPanel panel = {}; panel.SetProjectRuntime(&runtime); const UIEditorHostedPanelDispatchEntry dispatchEntry = MakeProjectDispatchEntry(); panel.Update( dispatchEntry, { MakePointerButtonDown(520.0f, 180.0f, ::XCEngine::UI::UIPointerButton::Right) }); panel.Update( dispatchEntry, { MakePointerButtonDown(520.0f, 194.0f, ::XCEngine::UI::UIPointerButton::Left) }); EXPECT_TRUE(std::filesystem::exists(repo.Root() / "project/Assets/New Folder")); const UIEditorHostCommandEvaluationResult renameEvaluation = panel.EvaluateEditCommand("edit.rename"); EXPECT_TRUE(renameEvaluation.executable); EXPECT_EQ(renameEvaluation.message, "Rename project item 'New Folder'."); const auto& events = panel.GetFrameEvents(); ASSERT_FALSE(events.empty()); EXPECT_EQ(events.back().kind, ProjectPanel::EventKind::RenameRequested); EXPECT_EQ(events.back().itemId, "Assets/New Folder"); } TEST(ProjectPanelTests, FolderContextMenuCreateFolderUsesFolderTarget) { TemporaryRepo repo = {}; ASSERT_TRUE(std::filesystem::create_directories(repo.Root() / "project/Assets/FolderA")); EditorProjectRuntime runtime = {}; ASSERT_TRUE(runtime.Initialize(repo.Root() / "project")); ProjectPanel panel = {}; panel.SetProjectRuntime(&runtime); const UIEditorHostedPanelDispatchEntry dispatchEntry = MakeProjectDispatchEntry(); panel.Update( dispatchEntry, { MakePointerButtonDown(300.0f, 80.0f, ::XCEngine::UI::UIPointerButton::Right) }); panel.Update( dispatchEntry, { MakePointerButtonDown(320.0f, 120.0f, ::XCEngine::UI::UIPointerButton::Left) }); EXPECT_TRUE(std::filesystem::exists(repo.Root() / "project/Assets/FolderA/New Folder")); } TEST(ProjectPanelTests, InjectedRuntimeSelectionDrivesRenameWithoutPanelSelectionSync) { TemporaryRepo repo = {}; ASSERT_TRUE(repo.CreateDirectory("project/Assets/Scripts")); ASSERT_TRUE(repo.WriteFile("project/Assets/Scripts/Player.cs")); ASSERT_TRUE(repo.WriteFile("project/Assets/Scripts/Player.cs.meta")); EditorProjectRuntime runtime = {}; ASSERT_TRUE(runtime.Initialize(repo.Root() / "project")); ProjectPanel panel = {}; panel.SetProjectRuntime(&runtime); ASSERT_TRUE(runtime.NavigateToFolder("Assets/Scripts")); ASSERT_TRUE(runtime.SetSelection("Assets/Scripts/Player.cs")); const UIEditorHostCommandDispatchResult dispatch = panel.DispatchEditCommand("edit.rename"); EXPECT_TRUE(dispatch.commandExecuted); const auto& events = panel.GetFrameEvents(); ASSERT_EQ(events.size(), 1u); EXPECT_EQ(events[0].kind, ProjectPanel::EventKind::RenameRequested); EXPECT_EQ(events[0].source, ProjectPanel::EventSource::GridPrimary); EXPECT_EQ(events[0].itemId, "Assets/Scripts/Player.cs"); } TEST(ProjectPanelTests, InjectedRuntimeCurrentFolderDrivesRenameFallbackWithoutTreeSync) { TemporaryRepo repo = {}; ASSERT_TRUE(repo.CreateDirectory("project/Assets/FolderA")); EditorProjectRuntime runtime = {}; ASSERT_TRUE(runtime.Initialize(repo.Root() / "project")); ProjectPanel panel = {}; panel.SetProjectRuntime(&runtime); ASSERT_TRUE(runtime.NavigateToFolder("Assets/FolderA")); const UIEditorHostCommandEvaluationResult evaluation = panel.EvaluateEditCommand("edit.rename"); EXPECT_TRUE(evaluation.executable); EXPECT_EQ(evaluation.message, "Rename project item 'FolderA'."); } TEST(ProjectPanelTests, IconServiceCanBeConfiguredBeforeRuntimeInitialization) { TemporaryRepo repo = {}; ProjectPanel panel = {}; FakeIconService icons = {}; panel.SetIconService(&icons); EditorProjectRuntime runtime = {}; ASSERT_TRUE(runtime.Initialize(repo.Root() / "project")); panel.SetProjectRuntime(&runtime); const UIEditorHostCommandEvaluationResult evaluation = panel.EvaluateAssetCommand("assets.create_folder"); EXPECT_TRUE(evaluation.executable); } TEST(ProjectPanelTests, CopyPathCommandUsesInjectedSystemHost) { TemporaryRepo repo = {}; ASSERT_TRUE(repo.CreateDirectory("project/Assets/Scripts")); ASSERT_TRUE(repo.WriteFile("project/Assets/Scripts/Player.cs")); EditorProjectRuntime runtime = {}; ASSERT_TRUE(runtime.Initialize(repo.Root() / "project")); ASSERT_TRUE(runtime.NavigateToFolder("Assets/Scripts")); ASSERT_TRUE(runtime.SetSelection("Assets/Scripts/Player.cs")); ProjectPanel panel = {}; FakeSystemInteractionHost systemHost = {}; panel.SetProjectRuntime(&runtime); panel.SetSystemInteractionHost(&systemHost); const UIEditorHostCommandEvaluationResult evaluation = panel.EvaluateAssetCommand("assets.copy_path"); EXPECT_TRUE(evaluation.executable); const UIEditorHostCommandDispatchResult dispatch = panel.DispatchAssetCommand("assets.copy_path"); EXPECT_TRUE(dispatch.commandExecuted); EXPECT_EQ(systemHost.clipboardCallCount, 1); EXPECT_EQ(systemHost.lastClipboardText, "Assets/Scripts/Player.cs"); } TEST(ProjectPanelTests, ShowInExplorerCommandUsesInjectedSystemHost) { TemporaryRepo repo = {}; ASSERT_TRUE(repo.CreateDirectory("project/Assets/Scripts")); ASSERT_TRUE(repo.WriteFile("project/Assets/Scripts/Player.cs")); EditorProjectRuntime runtime = {}; ASSERT_TRUE(runtime.Initialize(repo.Root() / "project")); ASSERT_TRUE(runtime.NavigateToFolder("Assets/Scripts")); ASSERT_TRUE(runtime.SetSelection("Assets/Scripts/Player.cs")); ProjectPanel panel = {}; FakeSystemInteractionHost systemHost = {}; panel.SetProjectRuntime(&runtime); panel.SetSystemInteractionHost(&systemHost); const UIEditorHostCommandDispatchResult dispatch = panel.DispatchAssetCommand("assets.show_in_explorer"); EXPECT_TRUE(dispatch.commandExecuted); EXPECT_EQ(systemHost.revealCallCount, 1); EXPECT_EQ( systemHost.lastRevealPath.lexically_normal(), (repo.Root() / "project/Assets/Scripts/Player.cs").lexically_normal()); EXPECT_TRUE(systemHost.lastRevealSelectTarget); } } // namespace } // namespace XCEngine::UI::Editor::App