Refactor new editor state ownership model

This commit is contained in:
2026-04-19 04:36:52 +08:00
parent 48bfde28e3
commit f45b34a03a
46 changed files with 1979 additions and 217 deletions

View File

@@ -118,6 +118,7 @@ if(TARGET XCUIEditorAppLib)
list(APPEND EDITOR_APP_FEATURE_TEST_SOURCES
test_editor_host_command_bridge.cpp
test_editor_shell_asset_validation.cpp
test_editor_window_workspace_store.cpp
test_structured_editor_shell.cpp
test_editor_window_input_routing.cpp
test_ui_editor_panel_registry.cpp

View File

@@ -1,12 +1,15 @@
#include <gtest/gtest.h>
#include "Composition/EditorPanelIds.h"
#include "Commands/EditorEditCommandRoute.h"
#include "Commands/EditorHostCommandBridge.h"
#include "State/EditorCommandFocusService.h"
#include "State/EditorSession.h"
namespace {
using XCEngine::UI::Editor::App::EditorActionRoute;
using XCEngine::UI::Editor::App::EditorCommandFocusService;
using XCEngine::UI::Editor::App::EditorEditCommandRoute;
using XCEngine::UI::Editor::App::EditorHostCommandBridge;
using XCEngine::UI::Editor::App::EditorSession;
@@ -51,7 +54,8 @@ public:
TEST(EditorHostCommandBridgeTest, HierarchyEditCommandsDelegateToBoundRoute) {
EditorSession session = {};
session.activeRoute = EditorActionRoute::Hierarchy;
EditorCommandFocusService commandFocus = {};
commandFocus.ClaimFocus(EditorActionRoute::Hierarchy);
StubEditCommandRoute hierarchyRoute = {};
hierarchyRoute.evaluationResult.executable = true;
@@ -61,6 +65,7 @@ TEST(EditorHostCommandBridgeTest, HierarchyEditCommandsDelegateToBoundRoute) {
EditorHostCommandBridge bridge = {};
bridge.BindSession(session);
bridge.BindCommandFocusService(commandFocus);
bridge.BindEditCommandRoutes(&hierarchyRoute, nullptr, nullptr);
const UIEditorHostCommandEvaluationResult evaluation =
@@ -78,7 +83,6 @@ TEST(EditorHostCommandBridgeTest, HierarchyEditCommandsDelegateToBoundRoute) {
TEST(EditorHostCommandBridgeTest, UnsupportedHostCommandsUseHonestMessages) {
EditorSession session = {};
session.activeRoute = EditorActionRoute::None;
EditorHostCommandBridge bridge = {};
bridge.BindSession(session);
@@ -100,7 +104,6 @@ TEST(EditorHostCommandBridgeTest, UnsupportedHostCommandsUseHonestMessages) {
TEST(EditorHostCommandBridgeTest, AssetCommandsDelegateToProjectRoute) {
EditorSession session = {};
session.activeRoute = EditorActionRoute::Hierarchy;
StubEditCommandRoute projectRoute = {};
projectRoute.assetEvaluationResult.executable = true;
@@ -127,7 +130,8 @@ TEST(EditorHostCommandBridgeTest, AssetCommandsDelegateToProjectRoute) {
TEST(EditorHostCommandBridgeTest, SceneEditCommandsDelegateToBoundSceneRoute) {
EditorSession session = {};
session.activeRoute = EditorActionRoute::Scene;
EditorCommandFocusService commandFocus = {};
commandFocus.ClaimFocus(EditorActionRoute::Scene);
StubEditCommandRoute sceneRoute = {};
sceneRoute.evaluationResult.executable = true;
@@ -137,6 +141,7 @@ TEST(EditorHostCommandBridgeTest, SceneEditCommandsDelegateToBoundSceneRoute) {
EditorHostCommandBridge bridge = {};
bridge.BindSession(session);
bridge.BindCommandFocusService(commandFocus);
bridge.BindEditCommandRoutes(nullptr, nullptr, &sceneRoute);
const UIEditorHostCommandEvaluationResult evaluation =
@@ -154,7 +159,8 @@ TEST(EditorHostCommandBridgeTest, SceneEditCommandsDelegateToBoundSceneRoute) {
TEST(EditorHostCommandBridgeTest, InspectorEditCommandsDelegateToBoundInspectorRoute) {
EditorSession session = {};
session.activeRoute = EditorActionRoute::Inspector;
EditorCommandFocusService commandFocus = {};
commandFocus.ClaimFocus(EditorActionRoute::Inspector);
StubEditCommandRoute inspectorRoute = {};
inspectorRoute.evaluationResult.executable = true;
@@ -164,6 +170,7 @@ TEST(EditorHostCommandBridgeTest, InspectorEditCommandsDelegateToBoundInspectorR
EditorHostCommandBridge bridge = {};
bridge.BindSession(session);
bridge.BindCommandFocusService(commandFocus);
bridge.BindEditCommandRoutes(nullptr, nullptr, nullptr, &inspectorRoute);
const UIEditorHostCommandEvaluationResult evaluation =
@@ -179,4 +186,50 @@ TEST(EditorHostCommandBridgeTest, InspectorEditCommandsDelegateToBoundInspectorR
EXPECT_EQ(inspectorRoute.lastDispatchedCommandId, "edit.delete");
}
TEST(EditorHostCommandBridgeTest, ActivePanelRouteIsUsedAsFallbackWhenNoExplicitCommandFocusExists) {
EditorSession session = {};
session.activePanelId = XCEngine::UI::Editor::App::kHierarchyPanelId;
StubEditCommandRoute hierarchyRoute = {};
hierarchyRoute.evaluationResult.executable = true;
hierarchyRoute.evaluationResult.message = "Hierarchy route owns rename.";
EditorHostCommandBridge bridge = {};
bridge.BindSession(session);
bridge.BindEditCommandRoutes(&hierarchyRoute, nullptr, nullptr);
const UIEditorHostCommandEvaluationResult evaluation =
bridge.EvaluateHostCommand("edit.rename");
EXPECT_TRUE(evaluation.executable);
EXPECT_EQ(evaluation.message, "Hierarchy route owns rename.");
}
TEST(EditorHostCommandBridgeTest, ExplicitCommandFocusOverridesActivePanelFallback) {
EditorSession session = {};
session.activePanelId = XCEngine::UI::Editor::App::kProjectPanelId;
EditorCommandFocusService commandFocus = {};
commandFocus.ClaimFocus(EditorActionRoute::Scene);
StubEditCommandRoute projectRoute = {};
projectRoute.evaluationResult.executable = true;
projectRoute.evaluationResult.message = "Project route.";
StubEditCommandRoute sceneRoute = {};
sceneRoute.evaluationResult.executable = true;
sceneRoute.evaluationResult.message = "Scene route.";
EditorHostCommandBridge bridge = {};
bridge.BindSession(session);
bridge.BindCommandFocusService(commandFocus);
bridge.BindEditCommandRoutes(nullptr, &projectRoute, &sceneRoute);
const UIEditorHostCommandEvaluationResult evaluation =
bridge.EvaluateHostCommand("edit.undo");
EXPECT_TRUE(evaluation.executable);
EXPECT_EQ(evaluation.message, "Scene route.");
EXPECT_EQ(sceneRoute.lastEvaluatedCommandId, "edit.undo");
EXPECT_TRUE(projectRoute.lastEvaluatedCommandId.empty());
}
} // namespace

View File

@@ -1,4 +1,5 @@
#include "Project/EditorProjectRuntime.h"
#include "State/EditorSelectionService.h"
#include <gtest/gtest.h>
@@ -168,5 +169,26 @@ TEST(EditorProjectRuntimeTests, RenameSelectedItemRemapsSelectionAndDeleteClears
EXPECT_EQ(runtime.GetSelection().kind, EditorSelectionKind::None);
}
TEST(EditorProjectRuntimeTests, BoundSelectionServiceBecomesTheSingleProjectSelectionSource) {
TemporaryRepo repo = {};
ASSERT_TRUE(repo.CreateDirectory("project/Assets/Scripts"));
ASSERT_TRUE(repo.WriteFile("project/Assets/Scripts/Player.cs"));
EditorSelectionService selectionService = {};
EditorProjectRuntime runtime = {};
ASSERT_TRUE(runtime.Initialize(repo.Root()));
runtime.BindSelectionService(&selectionService);
ASSERT_TRUE(runtime.NavigateToFolder("Assets/Scripts"));
ASSERT_TRUE(runtime.SetSelection("Assets/Scripts/Player.cs"));
EXPECT_EQ(selectionService.GetSelection().kind, EditorSelectionKind::ProjectItem);
EXPECT_EQ(selectionService.GetSelection().itemId, "Assets/Scripts/Player.cs");
EXPECT_EQ(runtime.GetSelection().itemId, "Assets/Scripts/Player.cs");
runtime.ClearSelection();
EXPECT_EQ(selectionService.GetSelection().kind, EditorSelectionKind::None);
EXPECT_FALSE(runtime.HasSelection());
}
} // namespace
} // namespace XCEngine::UI::Editor::App

View File

@@ -0,0 +1,160 @@
#include <gtest/gtest.h>
#include "Platform/Win32/WindowManager/EditorWindowWorkspaceStore.h"
#include <XCEditor/Workspace/UIEditorWorkspaceController.h>
#include <XCEditor/Workspace/UIEditorWorkspaceModel.h>
#include <XCEditor/Workspace/UIEditorWorkspaceQueries.h>
namespace {
using XCEngine::UI::Editor::App::Internal::EditorWindowWorkspaceStore;
using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController;
using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel;
using XCEngine::UI::Editor::BuildUIEditorWorkspaceSingleTabStack;
using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit;
using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack;
using XCEngine::UI::Editor::ContainsUIEditorWorkspacePanel;
using XCEngine::UI::Editor::FindUIEditorWindowWorkspaceState;
using XCEngine::UI::Editor::UIEditorPanelRegistry;
using XCEngine::UI::Editor::UIEditorWindowWorkspaceOperationStatus;
using XCEngine::UI::Editor::UIEditorWorkspaceCommand;
using XCEngine::UI::Editor::UIEditorWorkspaceCommandKind;
using XCEngine::UI::Editor::UIEditorWorkspaceCommandResult;
using XCEngine::UI::Editor::UIEditorWorkspaceCommandStatus;
using XCEngine::UI::Editor::UIEditorWorkspaceModel;
using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis;
UIEditorPanelRegistry BuildPanelRegistry() {
UIEditorPanelRegistry registry = {};
registry.panels = {
{ "doc-a", "Document A", {}, true, true, true },
{ "doc-b", "Document B", {}, true, true, true },
{ "inspector", "Inspector", {}, true, true, true },
};
return registry;
}
UIEditorWorkspaceModel BuildWorkspace() {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceSplit(
"root-split",
UIEditorWorkspaceSplitAxis::Horizontal,
0.7f,
BuildUIEditorWorkspaceTabStack(
"document-tabs",
{
BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true),
BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true),
},
0u),
BuildUIEditorWorkspaceSingleTabStack(
"inspector-panel",
"inspector",
"Inspector",
true));
workspace.activePanelId = "doc-a";
return workspace;
}
} // namespace
TEST(EditorWindowWorkspaceStoreTest, RegistersPrimaryWindowProjectionAsAuthoritativeState) {
EditorWindowWorkspaceStore store(BuildPanelRegistry());
auto workspaceController =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
std::string error = {};
ASSERT_TRUE(store.RegisterWindowProjection("main", true, workspaceController, error))
<< error;
ASSERT_TRUE(store.HasState());
ASSERT_EQ(store.GetWindowSet().primaryWindowId, "main");
ASSERT_EQ(store.GetWindowSet().activeWindowId, "main");
ASSERT_EQ(store.GetWindowSet().windows.size(), 1u);
const auto* mainWindow = FindUIEditorWindowWorkspaceState(store.GetWindowSet(), "main");
ASSERT_NE(mainWindow, nullptr);
EXPECT_EQ(mainWindow->workspace.activePanelId, "doc-a");
EXPECT_TRUE(ContainsUIEditorWorkspacePanel(mainWindow->workspace, "doc-b"));
}
TEST(EditorWindowWorkspaceStoreTest, CommitsWindowLocalProjectionBackIntoCentralStore) {
EditorWindowWorkspaceStore store(BuildPanelRegistry());
auto workspaceController =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
std::string error = {};
ASSERT_TRUE(store.RegisterWindowProjection("main", true, workspaceController, error))
<< error;
const UIEditorWorkspaceCommandResult activateResult = workspaceController.Dispatch(
UIEditorWorkspaceCommand{
UIEditorWorkspaceCommandKind::ActivatePanel,
"doc-b",
});
ASSERT_EQ(activateResult.status, UIEditorWorkspaceCommandStatus::Changed);
ASSERT_TRUE(store.CommitWindowProjection("main", workspaceController, error))
<< error;
const auto* mainWindow = FindUIEditorWindowWorkspaceState(store.GetWindowSet(), "main");
ASSERT_NE(mainWindow, nullptr);
EXPECT_EQ(mainWindow->workspace.activePanelId, "doc-b");
}
TEST(EditorWindowWorkspaceStoreTest, AppliesCrossWindowMutationAgainstCentralStore) {
EditorWindowWorkspaceStore store(BuildPanelRegistry());
const auto workspaceController =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
std::string error = {};
ASSERT_TRUE(store.RegisterWindowProjection("main", true, workspaceController, error))
<< error;
auto mutationController = store.BuildMutationController();
const auto detachResult = mutationController.DetachPanelToNewWindow(
"main",
"document-tabs",
"doc-b",
"doc-b-window");
ASSERT_EQ(detachResult.status, UIEditorWindowWorkspaceOperationStatus::Changed);
ASSERT_TRUE(store.ValidateWindowSet(mutationController.GetWindowSet(), error))
<< error;
store.ReplaceWindowSet(mutationController.GetWindowSet());
ASSERT_EQ(store.GetWindowSet().windows.size(), 2u);
EXPECT_EQ(store.GetWindowSet().activeWindowId, "doc-b-window");
const auto* detachedWindow =
FindUIEditorWindowWorkspaceState(store.GetWindowSet(), "doc-b-window");
ASSERT_NE(detachedWindow, nullptr);
EXPECT_TRUE(ContainsUIEditorWorkspacePanel(detachedWindow->workspace, "doc-b"));
}
TEST(EditorWindowWorkspaceStoreTest, RemovingDetachedWindowRepairsActiveWindowReference) {
EditorWindowWorkspaceStore store(BuildPanelRegistry());
const auto workspaceController =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
std::string error = {};
ASSERT_TRUE(store.RegisterWindowProjection("main", true, workspaceController, error))
<< error;
auto mutationController = store.BuildMutationController();
ASSERT_EQ(
mutationController.DetachPanelToNewWindow(
"main",
"document-tabs",
"doc-b",
"doc-b-window").status,
UIEditorWindowWorkspaceOperationStatus::Changed);
ASSERT_TRUE(store.ValidateWindowSet(mutationController.GetWindowSet(), error))
<< error;
store.ReplaceWindowSet(mutationController.GetWindowSet());
store.RemoveWindow("doc-b-window", false);
EXPECT_EQ(store.GetWindowSet().windows.size(), 1u);
EXPECT_EQ(store.GetWindowSet().primaryWindowId, "main");
EXPECT_EQ(store.GetWindowSet().activeWindowId, "main");
EXPECT_EQ(
FindUIEditorWindowWorkspaceState(store.GetWindowSet(), "doc-b-window"),
nullptr);
}

View File

@@ -2,7 +2,7 @@
#include "Features/Scene/SceneViewportController.h"
#include "Features/Inspector/InspectorSubject.h"
#include "Rendering/Viewport/ViewportHostService.h"
#include "State/EditorSelectionStamp.h"
#include "State/EditorSelectionService.h"
#include "Composition/EditorPanelIds.h"
#include <XCEditor/Viewport/UIEditorViewportInputBridge.h>
@@ -399,30 +399,35 @@ TEST(SceneViewportRuntimeTests, SelectionStampAdvancesOnSceneSelectionChanges) {
EXPECT_GT(runtime.GetSelectionStamp(), clearedStamp);
}
TEST(SceneViewportRuntimeTests, InspectorSelectionResolverFollowsLatestSelectionDomain) {
TEST(SceneViewportRuntimeTests, InspectorSelectionResolverFollowsUnifiedSelectionSnapshot) {
ScopedSceneManagerReset reset = {};
TemporaryProjectRoot projectRoot = {};
SaveMainScene(projectRoot, Math::Vector3(4.0f, 5.0f, 6.0f));
EditorSelectionService selectionService = {};
EditorSceneRuntime runtime = {};
ASSERT_TRUE(runtime.Initialize(projectRoot.Root()));
runtime.BindSelectionService(&selectionService);
runtime.EnsureSceneSelection();
ASSERT_TRUE(runtime.HasSceneSelection());
EditorSession session = {};
session.selection = selectionService.GetSelection();
EXPECT_EQ(
ResolveInspectorSelectionSource(EditorSession{}, runtime),
ResolveInspectorSelectionSource(session, runtime),
InspectorSelectionSource::Scene);
const InspectorSubject sceneSubject =
BuildInspectorSubject(EditorSession{}, runtime);
BuildInspectorSubject(session, runtime);
EXPECT_EQ(sceneSubject.kind, InspectorSubjectKind::SceneObject);
EXPECT_EQ(sceneSubject.source, InspectorSelectionSource::Scene);
EXPECT_EQ(sceneSubject.sceneObject.displayName, "Target");
EditorSession session = {};
session.selection.kind = EditorSelectionKind::ProjectItem;
session.selection.itemId = "asset:scene";
session.selection.displayName = "Main";
session.selection.absolutePath = projectRoot.MainScenePath();
session.selection.stamp = GenerateEditorSelectionStamp();
selectionService.SetProjectSelection(
"asset:scene",
"Main",
projectRoot.MainScenePath(),
false);
session.selection = selectionService.GetSelection();
EXPECT_EQ(
ResolveInspectorSelectionSource(session, runtime),
InspectorSelectionSource::Project);
@@ -432,24 +437,34 @@ TEST(SceneViewportRuntimeTests, InspectorSelectionResolverFollowsLatestSelection
EXPECT_EQ(projectSubject.source, InspectorSelectionSource::Project);
EXPECT_EQ(projectSubject.projectAsset.selection.itemId, "asset:scene");
runtime.EnsureSceneSelection();
session.selection = selectionService.GetSelection();
EXPECT_EQ(
ResolveInspectorSelectionSource(session, runtime),
InspectorSelectionSource::Project);
EXPECT_EQ(
BuildInspectorSubject(session, runtime).kind,
InspectorSubjectKind::ProjectAsset);
runtime.ClearSelection();
session.selection = selectionService.GetSelection();
EXPECT_EQ(
ResolveInspectorSelectionSource(session, runtime),
InspectorSelectionSource::None);
EXPECT_EQ(
BuildInspectorSubject(session, runtime).kind,
InspectorSubjectKind::None);
runtime.EnsureSceneSelection();
session.selection = selectionService.GetSelection();
EXPECT_EQ(
ResolveInspectorSelectionSource(session, runtime),
InspectorSelectionSource::Scene);
session.selection = {};
session.selection.stamp = GenerateEditorSelectionStamp();
EXPECT_EQ(
ResolveInspectorSelectionSource(session, runtime),
InspectorSelectionSource::None);
selectionService.SetProjectSelection(
"asset:scene",
"Main",
projectRoot.MainScenePath(),
false);
runtime.EnsureSceneSelection();
EXPECT_EQ(selectionService.GetSelection().kind, EditorSelectionKind::ProjectItem);
EXPECT_EQ(selectionService.GetSelection().itemId, "asset:scene");
}
TEST(SceneViewportRuntimeTests, RightMouseDragRotatesSceneCameraThroughViewportController) {