Build XCUI splitter foundation and test harness

This commit is contained in:
2026-04-06 03:17:53 +08:00
parent dc17685099
commit c7dc8d7484
77 changed files with 4749 additions and 542 deletions

View File

@@ -0,0 +1,40 @@
set(CORE_UI_TEST_SOURCES
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_shortcut_scope.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_layout.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_interaction.cpp
# Migration bridge: legacy XCUI unit coverage still lives under tests/Core/UI
# until it is moved into tests/UI/Core/unit without changing behavior.
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_core.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_expansion_model.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_flat_hierarchy_helpers.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_input_dispatcher.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_keyboard_navigation_model.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_property_edit_model.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_layout_engine.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_selection_model.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_text_editing.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_text_input_controller.cpp
)
add_executable(core_ui_tests ${CORE_UI_TEST_SOURCES})
if(MSVC)
set_target_properties(core_ui_tests PROPERTIES
LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib"
)
endif()
target_link_libraries(core_ui_tests
PRIVATE
XCEngine
GTest::gtest
GTest::gtest_main
)
target_include_directories(core_ui_tests PRIVATE
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/tests/Fixtures
)
include(GoogleTest)
gtest_discover_tests(core_ui_tests)

View File

@@ -0,0 +1,171 @@
#include <gtest/gtest.h>
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/Input/UIInputDispatcher.h>
#include <XCEngine/UI/Input/UIShortcutRegistry.h>
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::UI::UIInputDispatchDecision;
using XCEngine::UI::UIInputDispatcher;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIInputPath;
using XCEngine::UI::UIShortcutBinding;
using XCEngine::UI::UIShortcutContext;
using XCEngine::UI::UIShortcutRegistry;
using XCEngine::UI::UIShortcutScope;
UIInputEvent MakeCtrlPEvent() {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = static_cast<std::int32_t>(KeyCode::P);
event.modifiers.control = true;
return event;
}
} // namespace
TEST(UIShortcutScopeTest, RegistryUsesCommandScopeInsteadOfActivePathForShortcutResolution) {
UIShortcutRegistry registry = {};
UIShortcutBinding dragWidgetBinding = {};
dragWidgetBinding.scope = UIShortcutScope::Widget;
dragWidgetBinding.ownerId = 91u;
dragWidgetBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
dragWidgetBinding.chord.modifiers.control = true;
dragWidgetBinding.commandId = "drag.widget.command";
registry.RegisterBinding(dragWidgetBinding);
UIShortcutBinding panelBinding = {};
panelBinding.scope = UIShortcutScope::Panel;
panelBinding.ownerId = 20u;
panelBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
panelBinding.chord.modifiers.control = true;
panelBinding.commandId = "panel.command";
registry.RegisterBinding(panelBinding);
UIShortcutContext context = {};
context.focusedPath = { 10u, 20u, 30u };
context.activePath = { 90u, 91u };
context.commandScope.path = context.focusedPath;
context.commandScope.windowId = 10u;
context.commandScope.panelId = 20u;
context.commandScope.widgetId = 30u;
const auto match = registry.Match(MakeCtrlPEvent(), context);
ASSERT_TRUE(match.matched);
EXPECT_EQ(match.binding.commandId, "panel.command");
}
TEST(UIShortcutScopeTest, RegistryPrefersPanelThenWindowThenGlobalWithinFocusedCommandScope) {
UIShortcutRegistry registry = {};
UIShortcutBinding globalBinding = {};
globalBinding.scope = UIShortcutScope::Global;
globalBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
globalBinding.chord.modifiers.control = true;
globalBinding.commandId = "global.command";
registry.RegisterBinding(globalBinding);
UIShortcutBinding windowBinding = {};
windowBinding.scope = UIShortcutScope::Window;
windowBinding.ownerId = 10u;
windowBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
windowBinding.chord.modifiers.control = true;
windowBinding.commandId = "window.command";
registry.RegisterBinding(windowBinding);
UIShortcutBinding panelBinding = {};
panelBinding.scope = UIShortcutScope::Panel;
panelBinding.ownerId = 20u;
panelBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
panelBinding.chord.modifiers.control = true;
panelBinding.commandId = "panel.command";
registry.RegisterBinding(panelBinding);
UIShortcutContext context = {};
context.focusedPath = { 10u, 20u, 30u };
context.commandScope.path = context.focusedPath;
context.commandScope.windowId = 10u;
context.commandScope.panelId = 20u;
context.commandScope.widgetId = 30u;
const auto match = registry.Match(MakeCtrlPEvent(), context);
ASSERT_TRUE(match.matched);
EXPECT_EQ(match.binding.commandId, "panel.command");
}
TEST(UIShortcutScopeTest, InputDispatcherConsumesShortcutBeforeRoutingWhenCommandScopeMatches) {
UIInputDispatcher dispatcher{};
dispatcher.GetFocusController().SetFocusedPath({ 10u, 20u, 30u });
UIShortcutContext shortcutContext = {};
shortcutContext.commandScope.path = { 10u, 20u, 30u };
shortcutContext.commandScope.windowId = 10u;
shortcutContext.commandScope.panelId = 20u;
shortcutContext.commandScope.widgetId = 30u;
dispatcher.SetShortcutContext(shortcutContext);
UIShortcutBinding binding = {};
binding.scope = UIShortcutScope::Panel;
binding.ownerId = 20u;
binding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
binding.chord.modifiers.control = true;
binding.commandId = "panel.command";
dispatcher.GetShortcutRegistry().RegisterBinding(binding);
bool handlerCalled = false;
const auto summary = dispatcher.Dispatch(
MakeCtrlPEvent(),
{},
[&handlerCalled](const auto&) {
handlerCalled = true;
return UIInputDispatchDecision{};
});
EXPECT_TRUE(summary.shortcutMatched);
EXPECT_TRUE(summary.shortcutHandled);
EXPECT_FALSE(summary.shortcutSuppressed);
EXPECT_EQ(summary.commandId, "panel.command");
EXPECT_EQ(summary.shortcutScope, UIShortcutScope::Panel);
EXPECT_EQ(summary.shortcutOwnerId, 20u);
EXPECT_FALSE(handlerCalled);
}
TEST(UIShortcutScopeTest, InputDispatcherSuppressesShortcutWhileTextInputIsActiveButStillRoutesEvent) {
UIInputDispatcher dispatcher{};
dispatcher.GetFocusController().SetFocusedPath({ 10u, 20u, 30u });
UIShortcutContext shortcutContext = {};
shortcutContext.commandScope.path = { 10u, 20u, 30u };
shortcutContext.commandScope.windowId = 10u;
shortcutContext.commandScope.panelId = 20u;
shortcutContext.commandScope.widgetId = 30u;
shortcutContext.textInputActive = true;
dispatcher.SetShortcutContext(shortcutContext);
UIShortcutBinding binding = {};
binding.scope = UIShortcutScope::Panel;
binding.ownerId = 20u;
binding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
binding.chord.modifiers.control = true;
binding.commandId = "panel.command";
dispatcher.GetShortcutRegistry().RegisterBinding(binding);
bool handlerCalled = false;
const auto summary = dispatcher.Dispatch(
MakeCtrlPEvent(),
{},
[&handlerCalled](const auto&) {
handlerCalled = true;
return UIInputDispatchDecision{};
});
EXPECT_TRUE(summary.shortcutMatched);
EXPECT_FALSE(summary.shortcutHandled);
EXPECT_TRUE(summary.shortcutSuppressed);
EXPECT_EQ(summary.commandId, "panel.command");
EXPECT_TRUE(handlerCalled);
}

View File

@@ -0,0 +1,112 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Layout/UISplitterLayout.h>
#include <XCEngine/UI/Widgets/UISplitterInteraction.h>
namespace {
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::UI::Layout::UILayoutAxis;
using XCEngine::UI::Layout::ArrangeUISplitter;
using XCEngine::UI::Layout::UISplitterConstraints;
using XCEngine::UI::Layout::UISplitterLayoutResult;
using XCEngine::UI::Layout::UISplitterMetrics;
using XCEngine::UI::Widgets::BeginUISplitterDrag;
using XCEngine::UI::Widgets::EndUISplitterDrag;
using XCEngine::UI::Widgets::ExpandUISplitterHandleHitRect;
using XCEngine::UI::Widgets::HitTestUISplitterHandle;
using XCEngine::UI::Widgets::UISplitterDragState;
using XCEngine::UI::Widgets::UpdateUISplitterDrag;
void ExpectRect(
const UIRect& rect,
float x,
float y,
float width,
float height) {
EXPECT_FLOAT_EQ(rect.x, x);
EXPECT_FLOAT_EQ(rect.y, y);
EXPECT_FLOAT_EQ(rect.width, width);
EXPECT_FLOAT_EQ(rect.height, height);
}
} // namespace
TEST(UISplitterInteractionTest, HorizontalSplitterHitRectExpandsAcrossDividerWidth) {
const UIRect hitRect = ExpandUISplitterHandleHitRect(
UIRect(240.0f, 24.0f, 8.0f, 260.0f),
UILayoutAxis::Horizontal,
4.0f);
ExpectRect(hitRect, 236.0f, 24.0f, 16.0f, 260.0f);
}
TEST(UISplitterInteractionTest, VerticalSplitterHitRectExpandsAcrossDividerHeight) {
const UIRect hitRect = ExpandUISplitterHandleHitRect(
UIRect(16.0f, 180.0f, 420.0f, 10.0f),
UILayoutAxis::Vertical,
5.0f);
ExpectRect(hitRect, 16.0f, 175.0f, 420.0f, 20.0f);
}
TEST(UISplitterInteractionTest, HitTestUsesExpandedHorizontalHandleRect) {
EXPECT_TRUE(HitTestUISplitterHandle(
UIRect(240.0f, 24.0f, 8.0f, 260.0f),
UILayoutAxis::Horizontal,
UIPoint(236.5f, 80.0f),
4.0f));
EXPECT_FALSE(HitTestUISplitterHandle(
UIRect(240.0f, 24.0f, 8.0f, 260.0f),
UILayoutAxis::Horizontal,
UIPoint(235.0f, 80.0f),
4.0f));
}
TEST(UISplitterInteractionTest, HitTestUsesExpandedVerticalHandleRect) {
EXPECT_TRUE(HitTestUISplitterHandle(
UIRect(16.0f, 180.0f, 420.0f, 10.0f),
UILayoutAxis::Vertical,
UIPoint(90.0f, 176.0f),
5.0f));
EXPECT_FALSE(HitTestUISplitterHandle(
UIRect(16.0f, 180.0f, 420.0f, 10.0f),
UILayoutAxis::Vertical,
UIPoint(90.0f, 174.0f),
5.0f));
}
TEST(UISplitterInteractionTest, DragUpdateClampsPrimaryExtentAgainstConstraints) {
UISplitterConstraints constraints = {};
constraints.primaryMin = 120.0f;
constraints.secondaryMin = 180.0f;
const UISplitterMetrics metrics = { 10.0f, 18.0f };
const UIRect bounds(0.0f, 0.0f, 500.0f, 180.0f);
const UISplitterLayoutResult initialLayout = ArrangeUISplitter(
bounds,
UILayoutAxis::Horizontal,
0.5f,
constraints,
metrics);
UISplitterDragState dragState = {};
ASSERT_TRUE(BeginUISplitterDrag(
42u,
UILayoutAxis::Horizontal,
bounds,
initialLayout,
constraints,
metrics,
UIPoint(initialLayout.handleRect.x + 5.0f, 90.0f),
dragState));
UISplitterLayoutResult updatedLayout = {};
ASSERT_TRUE(UpdateUISplitterDrag(dragState, UIPoint(80.0f, 90.0f), updatedLayout));
EXPECT_FLOAT_EQ(updatedLayout.primaryExtent, 120.0f);
EXPECT_FLOAT_EQ(updatedLayout.secondaryExtent, 370.0f);
EndUISplitterDrag(dragState);
EXPECT_FALSE(dragState.active);
EXPECT_EQ(dragState.ownerId, 0u);
}

View File

@@ -0,0 +1,76 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Layout/UISplitterLayout.h>
namespace {
using XCEngine::UI::UIRect;
using XCEngine::UI::Layout::ArrangeUISplitter;
using XCEngine::UI::Layout::UILayoutAxis;
using XCEngine::UI::Layout::UISplitterConstraints;
using XCEngine::UI::Layout::UISplitterMetrics;
void ExpectRect(
const UIRect& rect,
float x,
float y,
float width,
float height) {
EXPECT_FLOAT_EQ(rect.x, x);
EXPECT_FLOAT_EQ(rect.y, y);
EXPECT_FLOAT_EQ(rect.width, width);
EXPECT_FLOAT_EQ(rect.height, height);
}
} // namespace
TEST(UISplitterLayoutTest, ArrangeHorizontalSplitterClampsPrimaryExtentToMinimum) {
UISplitterConstraints constraints = {};
constraints.primaryMin = 200.0f;
constraints.secondaryMin = 120.0f;
const auto result = ArrangeUISplitter(
UIRect(0.0f, 0.0f, 600.0f, 300.0f),
UILayoutAxis::Horizontal,
0.1f,
constraints,
UISplitterMetrics{ 10.0f, 18.0f });
ExpectRect(result.primaryRect, 0.0f, 0.0f, 200.0f, 300.0f);
ExpectRect(result.handleRect, 200.0f, 0.0f, 10.0f, 300.0f);
ExpectRect(result.secondaryRect, 210.0f, 0.0f, 390.0f, 300.0f);
EXPECT_NEAR(result.splitRatio, 200.0f / 590.0f, 0.0001f);
}
TEST(UISplitterLayoutTest, ArrangeVerticalSplitterClampsSecondaryMinimumAgainstRequestedRatio) {
UISplitterConstraints constraints = {};
constraints.primaryMin = 100.0f;
constraints.secondaryMin = 120.0f;
const auto result = ArrangeUISplitter(
UIRect(0.0f, 0.0f, 500.0f, 400.0f),
UILayoutAxis::Vertical,
0.9f,
constraints,
UISplitterMetrics{ 8.0f, 16.0f });
ExpectRect(result.primaryRect, 0.0f, 0.0f, 500.0f, 272.0f);
ExpectRect(result.handleRect, 0.0f, 272.0f, 500.0f, 8.0f);
ExpectRect(result.secondaryRect, 0.0f, 280.0f, 500.0f, 120.0f);
EXPECT_NEAR(result.splitRatio, 272.0f / 392.0f, 0.0001f);
}
TEST(UISplitterLayoutTest, HorizontalArrangementSplitsAvailableExtentAroundHandle) {
const auto result = ArrangeUISplitter(
UIRect(10.0f, 20.0f, 400.0f, 120.0f),
UILayoutAxis::Horizontal,
0.5f,
{},
UISplitterMetrics{ 8.0f, 14.0f });
EXPECT_FLOAT_EQ(result.primaryExtent, 196.0f);
EXPECT_FLOAT_EQ(result.secondaryExtent, 196.0f);
ExpectRect(result.primaryRect, 10.0f, 20.0f, 196.0f, 120.0f);
ExpectRect(result.handleRect, 206.0f, 20.0f, 8.0f, 120.0f);
ExpectRect(result.secondaryRect, 214.0f, 20.0f, 196.0f, 120.0f);
}