Build XCUI splitter foundation and test harness
This commit is contained in:
7
tests/UI/CMakeLists.txt
Normal file
7
tests/UI/CMakeLists.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
cmake_minimum_required(VERSION 3.15)
|
||||
|
||||
project(XCEngine_UITests)
|
||||
|
||||
add_subdirectory(Core)
|
||||
add_subdirectory(Runtime)
|
||||
add_subdirectory(Editor)
|
||||
17
tests/UI/Core/CMakeLists.txt
Normal file
17
tests/UI/Core/CMakeLists.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
cmake_minimum_required(VERSION 3.15)
|
||||
|
||||
project(XCEngine_CoreUITests)
|
||||
|
||||
add_subdirectory(unit)
|
||||
add_subdirectory(integration)
|
||||
|
||||
add_custom_target(core_ui_unit_tests
|
||||
DEPENDS
|
||||
core_ui_tests
|
||||
)
|
||||
|
||||
add_custom_target(core_ui_all_tests
|
||||
DEPENDS
|
||||
core_ui_unit_tests
|
||||
core_ui_integration_tests
|
||||
)
|
||||
1
tests/UI/Core/integration/CMakeLists.txt
Normal file
1
tests/UI/Core/integration/CMakeLists.txt
Normal file
@@ -0,0 +1 @@
|
||||
add_custom_target(core_ui_integration_tests)
|
||||
8
tests/UI/Core/integration/README.md
Normal file
8
tests/UI/Core/integration/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Core UI Integration Notes
|
||||
|
||||
The core XCUI lane currently validates shared primitives through automated unit tests.
|
||||
|
||||
Interactive validation belongs to:
|
||||
|
||||
- `tests/UI/Runtime/integration/` for game/runtime UI
|
||||
- `tests/UI/Editor/integration/` for editor UI
|
||||
40
tests/UI/Core/unit/CMakeLists.txt
Normal file
40
tests/UI/Core/unit/CMakeLists.txt
Normal 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)
|
||||
171
tests/UI/Core/unit/test_ui_shortcut_scope.cpp
Normal file
171
tests/UI/Core/unit/test_ui_shortcut_scope.cpp
Normal 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);
|
||||
}
|
||||
112
tests/UI/Core/unit/test_ui_splitter_interaction.cpp
Normal file
112
tests/UI/Core/unit/test_ui_splitter_interaction.cpp
Normal 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);
|
||||
}
|
||||
76
tests/UI/Core/unit/test_ui_splitter_layout.cpp
Normal file
76
tests/UI/Core/unit/test_ui_splitter_layout.cpp
Normal 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);
|
||||
}
|
||||
18
tests/UI/Editor/CMakeLists.txt
Normal file
18
tests/UI/Editor/CMakeLists.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
cmake_minimum_required(VERSION 3.15)
|
||||
|
||||
project(XCEngine_EditorUITests)
|
||||
|
||||
add_subdirectory(integration/shared)
|
||||
add_subdirectory(unit)
|
||||
add_subdirectory(integration)
|
||||
|
||||
add_custom_target(editor_ui_unit_tests
|
||||
DEPENDS
|
||||
editor_ui_tests
|
||||
)
|
||||
|
||||
add_custom_target(editor_ui_all_tests
|
||||
DEPENDS
|
||||
editor_ui_unit_tests
|
||||
editor_ui_integration_tests
|
||||
)
|
||||
8
tests/UI/Editor/integration/CMakeLists.txt
Normal file
8
tests/UI/Editor/integration/CMakeLists.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
add_subdirectory(input)
|
||||
add_subdirectory(layout)
|
||||
|
||||
add_custom_target(editor_ui_integration_tests
|
||||
DEPENDS
|
||||
editor_ui_input_integration_tests
|
||||
editor_ui_layout_integration_tests
|
||||
)
|
||||
22
tests/UI/Editor/integration/README.md
Normal file
22
tests/UI/Editor/integration/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Editor UI Integration Validation
|
||||
|
||||
This directory contains the manual XCUI validation system for editor-facing scenarios.
|
||||
|
||||
Structure:
|
||||
|
||||
- `shared/`: shared host, native renderer, screenshot helper, scenario registry
|
||||
- `input/`: input-related validation category
|
||||
- `layout/`: layout and shell-foundation validation category
|
||||
|
||||
Rules:
|
||||
|
||||
- One scenario directory maps to one executable.
|
||||
- Do not accumulate unrelated checks into one monolithic app.
|
||||
- Shared infrastructure belongs in `shared/`, not duplicated per scenario.
|
||||
- Screenshots are stored per scenario inside that scenario's `captures/` folder.
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
cmake --build build --config Debug --target editor_ui_integration_tests
|
||||
```
|
||||
10
tests/UI/Editor/integration/input/CMakeLists.txt
Normal file
10
tests/UI/Editor/integration/input/CMakeLists.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
add_subdirectory(keyboard_focus)
|
||||
add_subdirectory(pointer_states)
|
||||
add_subdirectory(shortcut_scope)
|
||||
|
||||
add_custom_target(editor_ui_input_integration_tests
|
||||
DEPENDS
|
||||
editor_ui_input_keyboard_focus_validation
|
||||
editor_ui_input_pointer_states_validation
|
||||
editor_ui_input_shortcut_scope_validation
|
||||
)
|
||||
9
tests/UI/Editor/integration/input/README.md
Normal file
9
tests/UI/Editor/integration/input/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Editor Input Integration
|
||||
|
||||
这个分类只放 editor 输入相关的手工验证场景。
|
||||
|
||||
规则:
|
||||
|
||||
- 一个场景目录对应一个独立 exe
|
||||
- 共享宿主层只放在 `integration/shared/`
|
||||
- 不允许把多个无关检查点塞进同一个 exe
|
||||
@@ -0,0 +1,35 @@
|
||||
set(EDITOR_UI_INPUT_KEYBOARD_FOCUS_RESOURCES
|
||||
View.xcui
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
|
||||
)
|
||||
|
||||
add_executable(editor_ui_input_keyboard_focus_validation WIN32
|
||||
main.cpp
|
||||
${EDITOR_UI_INPUT_KEYBOARD_FOCUS_RESOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_input_keyboard_focus_validation PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_input_keyboard_focus_validation PRIVATE
|
||||
UNICODE
|
||||
_UNICODE
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_input_keyboard_focus_validation PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_input_keyboard_focus_validation PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_input_keyboard_focus_validation PRIVATE
|
||||
editor_ui_integration_host
|
||||
)
|
||||
|
||||
set_target_properties(editor_ui_input_keyboard_focus_validation PROPERTIES
|
||||
OUTPUT_NAME "XCUIEditorInputKeyboardFocusValidation"
|
||||
)
|
||||
|
||||
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
|
||||
18
tests/UI/Editor/integration/input/keyboard_focus/README.md
Normal file
18
tests/UI/Editor/integration/input/keyboard_focus/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Keyboard Focus Validation
|
||||
|
||||
可执行 target:
|
||||
|
||||
- `editor_ui_input_keyboard_focus_validation`
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
build\tests\UI\Editor\integration\input\keyboard_focus\Debug\XCUIEditorInputKeyboardFocusValidation.exe
|
||||
```
|
||||
|
||||
检查点:
|
||||
|
||||
1. 按 `Tab`,焦点依次切换三个按钮
|
||||
2. 按 `Shift+Tab`,焦点反向切换
|
||||
3. 按 `Enter` 或 `Space`,当前 `focus` 按钮进入 `active`
|
||||
4. 松开按键后,`active` 清空
|
||||
30
tests/UI/Editor/integration/input/keyboard_focus/View.xcui
Normal file
30
tests/UI/Editor/integration/input/keyboard_focus/View.xcui
Normal file
@@ -0,0 +1,30 @@
|
||||
<View
|
||||
name="EditorInputKeyboardFocus"
|
||||
theme="../../shared/themes/editor_validation.xctheme">
|
||||
<Column padding="24" gap="16">
|
||||
<Card
|
||||
title="Editor Validation | Keyboard Focus"
|
||||
subtitle="当前批次:Tab 焦点遍历 | Enter / Space 激活"
|
||||
tone="accent"
|
||||
height="90">
|
||||
<Column gap="8">
|
||||
<Text text="这是 editor 侧验证场景,不承载 runtime 游戏 UI。" />
|
||||
<Text text="这一轮只检查键盘焦点和激活,不混入复杂 editor 面板。" />
|
||||
</Column>
|
||||
</Card>
|
||||
|
||||
<Card title="Keyboard Focus" subtitle="tab focus active" height="214">
|
||||
<Column gap="12">
|
||||
<Text text="只检查下面三个可聚焦按钮和右下角状态叠层。" />
|
||||
<Row gap="12">
|
||||
<Button id="focus-first" text="First Focus" />
|
||||
<Button id="focus-second" text="Second Focus" />
|
||||
<Button id="focus-third" text="Third Focus" />
|
||||
</Row>
|
||||
<Text text="1. 按 Tab:focus 应依次切到 First / Second / Third。" />
|
||||
<Text text="2. 按 Shift+Tab:focus 应反向切换。" />
|
||||
<Text text="3. focus 停在任一按钮后,按 Enter 或 Space:active 应出现;松开后 active 清空。" />
|
||||
</Column>
|
||||
</Card>
|
||||
</Column>
|
||||
</View>
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "Application.h"
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
|
||||
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
|
||||
hInstance,
|
||||
nCmdShow,
|
||||
"editor.input.keyboard_focus");
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
set(EDITOR_UI_INPUT_POINTER_STATES_RESOURCES
|
||||
View.xcui
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
|
||||
)
|
||||
|
||||
add_executable(editor_ui_input_pointer_states_validation WIN32
|
||||
main.cpp
|
||||
${EDITOR_UI_INPUT_POINTER_STATES_RESOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_input_pointer_states_validation PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_input_pointer_states_validation PRIVATE
|
||||
UNICODE
|
||||
_UNICODE
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_input_pointer_states_validation PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_input_pointer_states_validation PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_input_pointer_states_validation PRIVATE
|
||||
editor_ui_integration_host
|
||||
)
|
||||
|
||||
set_target_properties(editor_ui_input_pointer_states_validation PROPERTIES
|
||||
OUTPUT_NAME "XCUIEditorInputPointerStatesValidation"
|
||||
)
|
||||
|
||||
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
|
||||
17
tests/UI/Editor/integration/input/pointer_states/README.md
Normal file
17
tests/UI/Editor/integration/input/pointer_states/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Pointer States Validation
|
||||
|
||||
可执行 target:
|
||||
|
||||
- `editor_ui_input_pointer_states_validation`
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
build\tests\UI\Editor\integration\input\pointer_states\Debug\XCUIEditorInputPointerStatesValidation.exe
|
||||
```
|
||||
|
||||
检查点:
|
||||
|
||||
1. hover 左侧按钮,只应变化 `hover`
|
||||
2. 按住中间按钮,应看到 `focus`、`active`、`capture`
|
||||
3. 拖到右侧再松开,应看到 `capture` 清空,route 转到新的目标
|
||||
30
tests/UI/Editor/integration/input/pointer_states/View.xcui
Normal file
30
tests/UI/Editor/integration/input/pointer_states/View.xcui
Normal file
@@ -0,0 +1,30 @@
|
||||
<View
|
||||
name="EditorInputPointerStates"
|
||||
theme="../../shared/themes/editor_validation.xctheme">
|
||||
<Column padding="24" gap="16">
|
||||
<Card
|
||||
title="Editor Validation | Pointer States"
|
||||
subtitle="当前批次:鼠标 hover / focus / active / capture"
|
||||
tone="accent"
|
||||
height="90">
|
||||
<Column gap="8">
|
||||
<Text text="这是 editor 侧验证场景,不承载 runtime 游戏 UI。" />
|
||||
<Text text="这一轮只检查鼠标输入状态,不混入别的控件实验。" />
|
||||
</Column>
|
||||
</Card>
|
||||
|
||||
<Card title="Pointer Input" subtitle="hover focus active capture" height="196">
|
||||
<Column gap="12">
|
||||
<Text text="这一轮只需要检查下面这三个按钮。" />
|
||||
<Row gap="12">
|
||||
<Button id="input-hover" text="Hover / Focus" />
|
||||
<Button id="input-capture" text="Pointer Capture" capturePointer="true" />
|
||||
<Button id="input-route" text="Route Target" />
|
||||
</Row>
|
||||
<Text text="1. 鼠标移到左侧按钮:hover 应变化,focus 保持空。" />
|
||||
<Text text="2. 按住中间按钮:focus、active、capture 都应留在中间。" />
|
||||
<Text text="3. 拖到右侧再松开:hover 移到右侧,capture 清空,focus 仍留中间。" />
|
||||
</Column>
|
||||
</Card>
|
||||
</Column>
|
||||
</View>
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "Application.h"
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
|
||||
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
|
||||
hInstance,
|
||||
nCmdShow,
|
||||
"editor.input.pointer_states");
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
set(EDITOR_UI_INPUT_SHORTCUT_SCOPE_RESOURCES
|
||||
View.xcui
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
|
||||
)
|
||||
|
||||
add_executable(editor_ui_input_shortcut_scope_validation WIN32
|
||||
main.cpp
|
||||
${EDITOR_UI_INPUT_SHORTCUT_SCOPE_RESOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_input_shortcut_scope_validation PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_input_shortcut_scope_validation PRIVATE
|
||||
UNICODE
|
||||
_UNICODE
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_input_shortcut_scope_validation PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_input_shortcut_scope_validation PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_input_shortcut_scope_validation PRIVATE
|
||||
editor_ui_integration_host
|
||||
)
|
||||
|
||||
set_target_properties(editor_ui_input_shortcut_scope_validation PROPERTIES
|
||||
OUTPUT_NAME "XCUIEditorInputShortcutScopeValidation"
|
||||
)
|
||||
|
||||
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
|
||||
69
tests/UI/Editor/integration/input/shortcut_scope/View.xcui
Normal file
69
tests/UI/Editor/integration/input/shortcut_scope/View.xcui
Normal file
@@ -0,0 +1,69 @@
|
||||
<View
|
||||
name="EditorInputShortcutScope"
|
||||
theme="../../shared/themes/editor_validation.xctheme"
|
||||
shortcut="Ctrl+P"
|
||||
shortcutCommand="global.command"
|
||||
shortcutScope="global">
|
||||
<Column padding="20" gap="12">
|
||||
<Card
|
||||
title="Editor Validation | Shortcut Scope"
|
||||
subtitle="验证功能:Editor shortcut scope 路由与 text input suppression"
|
||||
tone="accent"
|
||||
height="100">
|
||||
<Column gap="6">
|
||||
<Text text="功能 1:验证 Ctrl+P 在 Widget / Panel / Window / Global 间按优先级命中 shortcut。" />
|
||||
<Text text="功能 2:验证 Text Input Proxy 会抑制 Ctrl+P 和 Tab 焦点遍历。" />
|
||||
</Column>
|
||||
</Card>
|
||||
|
||||
<Button id="global-focus" text="Global Focus" />
|
||||
|
||||
<Card
|
||||
id="window-shell"
|
||||
title="Window Scope"
|
||||
subtitle="Ctrl+P -> window.command"
|
||||
shortcutScopeRoot="window"
|
||||
shortcut="Ctrl+P"
|
||||
shortcutCommand="window.command"
|
||||
shortcutScope="window">
|
||||
<Column gap="10">
|
||||
<Text text="先检查优先级:widget > panel > window > global。" />
|
||||
<Button id="window-focus" text="Window Focus" />
|
||||
|
||||
<Card
|
||||
id="panel-shell"
|
||||
title="Panel Scope"
|
||||
subtitle="Ctrl+P -> panel.command"
|
||||
shortcutScopeRoot="panel"
|
||||
shortcut="Ctrl+P"
|
||||
shortcutCommand="panel.command"
|
||||
shortcutScope="panel">
|
||||
<Column gap="10">
|
||||
<Button id="panel-focus" text="Panel Focus" />
|
||||
|
||||
<Card
|
||||
id="widget-shell"
|
||||
title="Widget Scope"
|
||||
subtitle="Ctrl+P -> widget.command"
|
||||
tone="accent-alt"
|
||||
shortcutScopeRoot="widget"
|
||||
shortcut="Ctrl+P"
|
||||
shortcutCommand="widget.command"
|
||||
shortcutScope="widget">
|
||||
<Column gap="10">
|
||||
<Button id="widget-focus" text="Widget Focus" />
|
||||
</Column>
|
||||
</Card>
|
||||
|
||||
<Button id="text-input" text="Text Input Proxy" textInput="true" />
|
||||
<Text text="操作指引:" />
|
||||
<Text text="1. 依次点 Widget / Panel / Window / Global Focus,再按 Ctrl+P。" />
|
||||
<Text text="2. 右下角 Recent shortcut 应分别显示 widget / panel / window / global,且状态为 handled。" />
|
||||
<Text text="3. 点 Text Input Proxy 再按 Ctrl+P,Recent shortcut 状态应变为 suppressed。" />
|
||||
<Text text="4. 保持 Text Input Proxy focus 再按 Tab,Result 应显示 focus traversal suppressed,focus 不应跳走。" />
|
||||
</Column>
|
||||
</Card>
|
||||
</Column>
|
||||
</Card>
|
||||
</Column>
|
||||
</View>
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "Application.h"
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
|
||||
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
|
||||
hInstance,
|
||||
nCmdShow,
|
||||
"editor.input.shortcut_scope");
|
||||
}
|
||||
6
tests/UI/Editor/integration/layout/CMakeLists.txt
Normal file
6
tests/UI/Editor/integration/layout/CMakeLists.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
add_subdirectory(splitter_resize)
|
||||
|
||||
add_custom_target(editor_ui_layout_integration_tests
|
||||
DEPENDS
|
||||
editor_ui_layout_splitter_resize_validation
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
set(EDITOR_UI_LAYOUT_SPLITTER_RESIZE_RESOURCES
|
||||
View.xcui
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
|
||||
)
|
||||
|
||||
add_executable(editor_ui_layout_splitter_resize_validation WIN32
|
||||
main.cpp
|
||||
${EDITOR_UI_LAYOUT_SPLITTER_RESIZE_RESOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_layout_splitter_resize_validation PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_layout_splitter_resize_validation PRIVATE
|
||||
UNICODE
|
||||
_UNICODE
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_layout_splitter_resize_validation PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_layout_splitter_resize_validation PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_layout_splitter_resize_validation PRIVATE
|
||||
editor_ui_integration_host
|
||||
)
|
||||
|
||||
set_target_properties(editor_ui_layout_splitter_resize_validation PROPERTIES
|
||||
OUTPUT_NAME "XCUIEditorLayoutSplitterResizeValidation"
|
||||
)
|
||||
|
||||
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
|
||||
39
tests/UI/Editor/integration/layout/splitter_resize/View.xcui
Normal file
39
tests/UI/Editor/integration/layout/splitter_resize/View.xcui
Normal file
@@ -0,0 +1,39 @@
|
||||
<View
|
||||
name="EditorSplitterResizeValidation"
|
||||
theme="../../shared/themes/editor_validation.xctheme">
|
||||
<Column width="fill" height="fill" padding="20" gap="12">
|
||||
<Card
|
||||
title="功能:Splitter / pane resize"
|
||||
subtitle="这一轮只检查分割条拖拽和最小尺寸 clamp"
|
||||
tone="accent"
|
||||
height="128">
|
||||
<Column gap="6">
|
||||
<Text text="1. 鼠标移到中间 divider:右下角 Hover 应落到 workspace-splitter。" />
|
||||
<Text text="2. 按住左键拖拽:左右 pane 宽度应实时变化,Result 应出现 Splitter drag started / Splitter resized。" />
|
||||
<Text text="3. 向左右极限拖拽:布局应被 primaryMin / secondaryMin clamp 住,不应穿透。" />
|
||||
<Text text="4. 松开左键:Result 应显示 Splitter drag finished。" />
|
||||
</Column>
|
||||
</Card>
|
||||
|
||||
<Splitter
|
||||
id="workspace-splitter"
|
||||
axis="horizontal"
|
||||
splitRatio="0.38"
|
||||
splitterSize="10"
|
||||
splitterHitSize="18"
|
||||
primaryMin="180"
|
||||
secondaryMin="220"
|
||||
height="fill">
|
||||
<Card id="left-pane" title="Left Empty Pane" subtitle="min 180" height="fill">
|
||||
<Column gap="8">
|
||||
<Text text="这里只保留空 pane,用来观察 resize。" />
|
||||
</Column>
|
||||
</Card>
|
||||
<Card id="right-pane" title="Right Empty Pane" subtitle="min 220" height="fill">
|
||||
<Column gap="8">
|
||||
<Text text="拖拽过程中不应出现翻转、穿透或抖动。" />
|
||||
</Column>
|
||||
</Card>
|
||||
</Splitter>
|
||||
</Column>
|
||||
</View>
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "Application.h"
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
|
||||
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
|
||||
hInstance,
|
||||
nCmdShow,
|
||||
"editor.layout.splitter_resize");
|
||||
}
|
||||
57
tests/UI/Editor/integration/shared/CMakeLists.txt
Normal file
57
tests/UI/Editor/integration/shared/CMakeLists.txt
Normal file
@@ -0,0 +1,57 @@
|
||||
file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH)
|
||||
|
||||
add_library(editor_ui_validation_registry STATIC
|
||||
src/EditorValidationScenario.cpp
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_validation_registry
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_validation_registry
|
||||
PUBLIC
|
||||
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_validation_registry PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_validation_registry PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_validation_registry
|
||||
PUBLIC
|
||||
XCEngine
|
||||
)
|
||||
|
||||
add_library(editor_ui_integration_host STATIC
|
||||
src/Application.cpp
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_integration_host
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/new_editor/include
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_integration_host
|
||||
PUBLIC
|
||||
UNICODE
|
||||
_UNICODE
|
||||
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_integration_host PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_integration_host PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_integration_host
|
||||
PUBLIC
|
||||
editor_ui_validation_registry
|
||||
XCNewEditorHost
|
||||
)
|
||||
782
tests/UI/Editor/integration/shared/src/Application.cpp
Normal file
782
tests/UI/Editor/integration/shared/src/Application.cpp
Normal file
@@ -0,0 +1,782 @@
|
||||
#include "Application.h"
|
||||
|
||||
#include <XCEngine/Input/InputTypes.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
|
||||
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
|
||||
#endif
|
||||
|
||||
namespace XCEngine::Tests::EditorUI {
|
||||
|
||||
namespace {
|
||||
|
||||
using ::XCEngine::UI::UIColor;
|
||||
using ::XCEngine::UI::UIDrawData;
|
||||
using ::XCEngine::UI::UIDrawList;
|
||||
using ::XCEngine::UI::UIInputEvent;
|
||||
using ::XCEngine::UI::UIInputEventType;
|
||||
using ::XCEngine::UI::UIPoint;
|
||||
using ::XCEngine::UI::UIPointerButton;
|
||||
using ::XCEngine::UI::UIRect;
|
||||
using ::XCEngine::UI::Runtime::UIScreenFrameInput;
|
||||
using ::XCEngine::Input::KeyCode;
|
||||
|
||||
constexpr const wchar_t* kWindowClassName = L"XCUIEditorValidationHost";
|
||||
constexpr const wchar_t* kWindowTitle = L"XCUI Editor Validation";
|
||||
constexpr auto kReloadPollInterval = std::chrono::milliseconds(150);
|
||||
|
||||
constexpr UIColor kOverlayBgColor(0.10f, 0.10f, 0.10f, 0.95f);
|
||||
constexpr UIColor kOverlayBorderColor(0.25f, 0.25f, 0.25f, 1.0f);
|
||||
constexpr UIColor kOverlayTextPrimary(0.93f, 0.93f, 0.93f, 1.0f);
|
||||
constexpr UIColor kOverlayTextMuted(0.70f, 0.70f, 0.70f, 1.0f);
|
||||
constexpr UIColor kOverlaySuccess(0.82f, 0.82f, 0.82f, 1.0f);
|
||||
constexpr UIColor kOverlayFallback(0.56f, 0.56f, 0.56f, 1.0f);
|
||||
|
||||
Application* GetApplicationFromWindow(HWND hwnd) {
|
||||
return reinterpret_cast<Application*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
|
||||
}
|
||||
|
||||
std::filesystem::path GetRepoRootPath() {
|
||||
std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT;
|
||||
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
|
||||
root = root.substr(1u, root.size() - 2u);
|
||||
}
|
||||
return std::filesystem::path(root).lexically_normal();
|
||||
}
|
||||
|
||||
std::string TruncateText(const std::string& text, std::size_t maxLength) {
|
||||
if (text.size() <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (maxLength <= 3u) {
|
||||
return text.substr(0, maxLength);
|
||||
}
|
||||
|
||||
return text.substr(0, maxLength - 3u) + "...";
|
||||
}
|
||||
|
||||
std::string ExtractStateKeyTail(const std::string& stateKey) {
|
||||
if (stateKey.empty()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const std::size_t separator = stateKey.find_last_of('/');
|
||||
if (separator == std::string::npos || separator + 1u >= stateKey.size()) {
|
||||
return stateKey;
|
||||
}
|
||||
|
||||
return stateKey.substr(separator + 1u);
|
||||
}
|
||||
|
||||
std::string FormatFloat(float value) {
|
||||
std::ostringstream stream;
|
||||
stream.setf(std::ios::fixed, std::ios::floatfield);
|
||||
stream.precision(1);
|
||||
stream << value;
|
||||
return stream.str();
|
||||
}
|
||||
|
||||
std::string FormatPoint(const UIPoint& point) {
|
||||
return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")";
|
||||
}
|
||||
|
||||
std::string FormatRect(const UIRect& rect) {
|
||||
return "(" + FormatFloat(rect.x) +
|
||||
", " + FormatFloat(rect.y) +
|
||||
", " + FormatFloat(rect.width) +
|
||||
", " + FormatFloat(rect.height) +
|
||||
")";
|
||||
}
|
||||
|
||||
std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) {
|
||||
switch (wParam) {
|
||||
case 'A': return static_cast<std::int32_t>(KeyCode::A);
|
||||
case 'B': return static_cast<std::int32_t>(KeyCode::B);
|
||||
case 'C': return static_cast<std::int32_t>(KeyCode::C);
|
||||
case 'D': return static_cast<std::int32_t>(KeyCode::D);
|
||||
case 'E': return static_cast<std::int32_t>(KeyCode::E);
|
||||
case 'F': return static_cast<std::int32_t>(KeyCode::F);
|
||||
case 'G': return static_cast<std::int32_t>(KeyCode::G);
|
||||
case 'H': return static_cast<std::int32_t>(KeyCode::H);
|
||||
case 'I': return static_cast<std::int32_t>(KeyCode::I);
|
||||
case 'J': return static_cast<std::int32_t>(KeyCode::J);
|
||||
case 'K': return static_cast<std::int32_t>(KeyCode::K);
|
||||
case 'L': return static_cast<std::int32_t>(KeyCode::L);
|
||||
case 'M': return static_cast<std::int32_t>(KeyCode::M);
|
||||
case 'N': return static_cast<std::int32_t>(KeyCode::N);
|
||||
case 'O': return static_cast<std::int32_t>(KeyCode::O);
|
||||
case 'P': return static_cast<std::int32_t>(KeyCode::P);
|
||||
case 'Q': return static_cast<std::int32_t>(KeyCode::Q);
|
||||
case 'R': return static_cast<std::int32_t>(KeyCode::R);
|
||||
case 'S': return static_cast<std::int32_t>(KeyCode::S);
|
||||
case 'T': return static_cast<std::int32_t>(KeyCode::T);
|
||||
case 'U': return static_cast<std::int32_t>(KeyCode::U);
|
||||
case 'V': return static_cast<std::int32_t>(KeyCode::V);
|
||||
case 'W': return static_cast<std::int32_t>(KeyCode::W);
|
||||
case 'X': return static_cast<std::int32_t>(KeyCode::X);
|
||||
case 'Y': return static_cast<std::int32_t>(KeyCode::Y);
|
||||
case 'Z': return static_cast<std::int32_t>(KeyCode::Z);
|
||||
case '0': return static_cast<std::int32_t>(KeyCode::Zero);
|
||||
case '1': return static_cast<std::int32_t>(KeyCode::One);
|
||||
case '2': return static_cast<std::int32_t>(KeyCode::Two);
|
||||
case '3': return static_cast<std::int32_t>(KeyCode::Three);
|
||||
case '4': return static_cast<std::int32_t>(KeyCode::Four);
|
||||
case '5': return static_cast<std::int32_t>(KeyCode::Five);
|
||||
case '6': return static_cast<std::int32_t>(KeyCode::Six);
|
||||
case '7': return static_cast<std::int32_t>(KeyCode::Seven);
|
||||
case '8': return static_cast<std::int32_t>(KeyCode::Eight);
|
||||
case '9': return static_cast<std::int32_t>(KeyCode::Nine);
|
||||
case VK_SPACE: return static_cast<std::int32_t>(KeyCode::Space);
|
||||
case VK_TAB: return static_cast<std::int32_t>(KeyCode::Tab);
|
||||
case VK_RETURN: return static_cast<std::int32_t>(KeyCode::Enter);
|
||||
case VK_ESCAPE: return static_cast<std::int32_t>(KeyCode::Escape);
|
||||
case VK_SHIFT: return static_cast<std::int32_t>(KeyCode::LeftShift);
|
||||
case VK_CONTROL: return static_cast<std::int32_t>(KeyCode::LeftCtrl);
|
||||
case VK_MENU: return static_cast<std::int32_t>(KeyCode::LeftAlt);
|
||||
case VK_UP: return static_cast<std::int32_t>(KeyCode::Up);
|
||||
case VK_DOWN: return static_cast<std::int32_t>(KeyCode::Down);
|
||||
case VK_LEFT: return static_cast<std::int32_t>(KeyCode::Left);
|
||||
case VK_RIGHT: return static_cast<std::int32_t>(KeyCode::Right);
|
||||
case VK_HOME: return static_cast<std::int32_t>(KeyCode::Home);
|
||||
case VK_END: return static_cast<std::int32_t>(KeyCode::End);
|
||||
case VK_PRIOR: return static_cast<std::int32_t>(KeyCode::PageUp);
|
||||
case VK_NEXT: return static_cast<std::int32_t>(KeyCode::PageDown);
|
||||
case VK_DELETE: return static_cast<std::int32_t>(KeyCode::Delete);
|
||||
case VK_BACK: return static_cast<std::int32_t>(KeyCode::Backspace);
|
||||
case VK_F1: return static_cast<std::int32_t>(KeyCode::F1);
|
||||
case VK_F2: return static_cast<std::int32_t>(KeyCode::F2);
|
||||
case VK_F3: return static_cast<std::int32_t>(KeyCode::F3);
|
||||
case VK_F4: return static_cast<std::int32_t>(KeyCode::F4);
|
||||
case VK_F5: return static_cast<std::int32_t>(KeyCode::F5);
|
||||
case VK_F6: return static_cast<std::int32_t>(KeyCode::F6);
|
||||
case VK_F7: return static_cast<std::int32_t>(KeyCode::F7);
|
||||
case VK_F8: return static_cast<std::int32_t>(KeyCode::F8);
|
||||
case VK_F9: return static_cast<std::int32_t>(KeyCode::F9);
|
||||
case VK_F10: return static_cast<std::int32_t>(KeyCode::F10);
|
||||
case VK_F11: return static_cast<std::int32_t>(KeyCode::F11);
|
||||
case VK_F12: return static_cast<std::int32_t>(KeyCode::F12);
|
||||
default: return static_cast<std::int32_t>(KeyCode::None);
|
||||
}
|
||||
}
|
||||
|
||||
bool IsRepeatKeyMessage(LPARAM lParam) {
|
||||
return (static_cast<unsigned long>(lParam) & (1ul << 30)) != 0ul;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Application::Application(std::string requestedScenarioId)
|
||||
: m_screenPlayer(m_documentHost)
|
||||
, m_requestedScenarioId(std::move(requestedScenarioId)) {
|
||||
}
|
||||
|
||||
int Application::Run(HINSTANCE hInstance, int nCmdShow) {
|
||||
if (!Initialize(hInstance, nCmdShow)) {
|
||||
Shutdown();
|
||||
return 1;
|
||||
}
|
||||
|
||||
MSG message = {};
|
||||
while (message.message != WM_QUIT) {
|
||||
if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) {
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
continue;
|
||||
}
|
||||
|
||||
RenderFrame();
|
||||
Sleep(8);
|
||||
}
|
||||
|
||||
Shutdown();
|
||||
return static_cast<int>(message.wParam);
|
||||
}
|
||||
|
||||
bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) {
|
||||
m_hInstance = hInstance;
|
||||
|
||||
WNDCLASSEXW windowClass = {};
|
||||
windowClass.cbSize = sizeof(windowClass);
|
||||
windowClass.style = CS_HREDRAW | CS_VREDRAW;
|
||||
windowClass.lpfnWndProc = &Application::WndProc;
|
||||
windowClass.hInstance = hInstance;
|
||||
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
windowClass.lpszClassName = kWindowClassName;
|
||||
|
||||
m_windowClassAtom = RegisterClassExW(&windowClass);
|
||||
if (m_windowClassAtom == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
m_hwnd = CreateWindowExW(
|
||||
0,
|
||||
kWindowClassName,
|
||||
kWindowTitle,
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
1440,
|
||||
900,
|
||||
nullptr,
|
||||
nullptr,
|
||||
hInstance,
|
||||
this);
|
||||
if (m_hwnd == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ShowWindow(m_hwnd, nCmdShow);
|
||||
UpdateWindow(m_hwnd);
|
||||
|
||||
if (!m_renderer.Initialize(m_hwnd)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
m_startTime = std::chrono::steady_clock::now();
|
||||
m_lastFrameTime = m_startTime;
|
||||
const EditorValidationScenario* initialScenario = m_requestedScenarioId.empty()
|
||||
? &GetDefaultEditorValidationScenario()
|
||||
: FindEditorValidationScenario(m_requestedScenarioId);
|
||||
if (initialScenario == nullptr) {
|
||||
initialScenario = &GetDefaultEditorValidationScenario();
|
||||
}
|
||||
m_autoScreenshot.Initialize(initialScenario->captureRootPath);
|
||||
LoadStructuredScreen("startup");
|
||||
return true;
|
||||
}
|
||||
|
||||
void Application::Shutdown() {
|
||||
m_autoScreenshot.Shutdown();
|
||||
m_screenPlayer.Unload();
|
||||
m_trackedFiles.clear();
|
||||
m_screenAsset = {};
|
||||
m_useStructuredScreen = false;
|
||||
m_runtimeStatus.clear();
|
||||
m_runtimeError.clear();
|
||||
m_frameIndex = 0;
|
||||
|
||||
m_renderer.Shutdown();
|
||||
|
||||
if (m_hwnd != nullptr && IsWindow(m_hwnd)) {
|
||||
DestroyWindow(m_hwnd);
|
||||
}
|
||||
m_hwnd = nullptr;
|
||||
|
||||
if (m_windowClassAtom != 0 && m_hInstance != nullptr) {
|
||||
UnregisterClassW(kWindowClassName, m_hInstance);
|
||||
m_windowClassAtom = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void Application::RenderFrame() {
|
||||
if (m_hwnd == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
RECT clientRect = {};
|
||||
GetClientRect(m_hwnd, &clientRect);
|
||||
const float width = static_cast<float>((std::max)(clientRect.right - clientRect.left, 1L));
|
||||
const float height = static_cast<float>((std::max)(clientRect.bottom - clientRect.top, 1L));
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
double deltaTimeSeconds = std::chrono::duration<double>(now - m_lastFrameTime).count();
|
||||
if (deltaTimeSeconds <= 0.0) {
|
||||
deltaTimeSeconds = 1.0 / 60.0;
|
||||
}
|
||||
m_lastFrameTime = now;
|
||||
|
||||
RefreshStructuredScreen();
|
||||
std::vector<UIInputEvent> frameEvents = std::move(m_pendingInputEvents);
|
||||
m_pendingInputEvents.clear();
|
||||
|
||||
UIDrawData drawData = {};
|
||||
if (m_useStructuredScreen && m_screenPlayer.IsLoaded()) {
|
||||
UIScreenFrameInput input = {};
|
||||
input.viewportRect = UIRect(0.0f, 0.0f, width, height);
|
||||
input.events = std::move(frameEvents);
|
||||
input.deltaTimeSeconds = deltaTimeSeconds;
|
||||
input.frameIndex = ++m_frameIndex;
|
||||
input.focused = GetForegroundWindow() == m_hwnd;
|
||||
|
||||
const auto& frame = m_screenPlayer.Update(input);
|
||||
for (const auto& drawList : frame.drawData.GetDrawLists()) {
|
||||
drawData.AddDrawList(drawList);
|
||||
}
|
||||
|
||||
m_runtimeStatus = m_activeScenario != nullptr
|
||||
? m_activeScenario->displayName
|
||||
: "Editor UI Validation";
|
||||
m_runtimeError = frame.errorMessage;
|
||||
}
|
||||
|
||||
if (drawData.Empty()) {
|
||||
m_runtimeStatus = "Editor UI Validation | Load Error";
|
||||
if (m_runtimeError.empty() && !m_screenPlayer.IsLoaded()) {
|
||||
m_runtimeError = m_screenPlayer.GetLastError();
|
||||
}
|
||||
}
|
||||
|
||||
AppendRuntimeOverlay(drawData, width, height);
|
||||
|
||||
const bool framePresented = m_renderer.Render(drawData);
|
||||
m_autoScreenshot.CaptureIfRequested(
|
||||
m_renderer,
|
||||
drawData,
|
||||
static_cast<unsigned int>(width),
|
||||
static_cast<unsigned int>(height),
|
||||
framePresented);
|
||||
}
|
||||
|
||||
void Application::OnResize(UINT width, UINT height) {
|
||||
if (width == 0 || height == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_renderer.Resize(width, height);
|
||||
}
|
||||
|
||||
void Application::QueuePointerEvent(UIInputEventType type, UIPointerButton button, WPARAM wParam, LPARAM lParam) {
|
||||
UIInputEvent event = {};
|
||||
event.type = type;
|
||||
event.pointerButton = button;
|
||||
event.position = UIPoint(
|
||||
static_cast<float>(GET_X_LPARAM(lParam)),
|
||||
static_cast<float>(GET_Y_LPARAM(lParam)));
|
||||
event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<size_t>(wParam));
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
void Application::QueuePointerLeaveEvent() {
|
||||
UIInputEvent event = {};
|
||||
event.type = UIInputEventType::PointerLeave;
|
||||
if (m_hwnd != nullptr) {
|
||||
POINT clientPoint = {};
|
||||
GetCursorPos(&clientPoint);
|
||||
ScreenToClient(m_hwnd, &clientPoint);
|
||||
event.position = UIPoint(static_cast<float>(clientPoint.x), static_cast<float>(clientPoint.y));
|
||||
}
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
void Application::QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam) {
|
||||
if (m_hwnd == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
POINT screenPoint = {
|
||||
GET_X_LPARAM(lParam),
|
||||
GET_Y_LPARAM(lParam)
|
||||
};
|
||||
ScreenToClient(m_hwnd, &screenPoint);
|
||||
|
||||
UIInputEvent event = {};
|
||||
event.type = UIInputEventType::PointerWheel;
|
||||
event.position = UIPoint(static_cast<float>(screenPoint.x), static_cast<float>(screenPoint.y));
|
||||
event.wheelDelta = static_cast<float>(wheelDelta);
|
||||
event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<size_t>(wParam));
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
void Application::QueueKeyEvent(UIInputEventType type, WPARAM wParam, LPARAM lParam) {
|
||||
UIInputEvent event = {};
|
||||
event.type = type;
|
||||
event.keyCode = MapVirtualKeyToUIKeyCode(wParam);
|
||||
event.modifiers = m_inputModifierTracker.ApplyKeyMessage(type, wParam, lParam);
|
||||
event.repeat = IsRepeatKeyMessage(lParam);
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
void Application::QueueCharacterEvent(WPARAM wParam, LPARAM) {
|
||||
UIInputEvent event = {};
|
||||
event.type = UIInputEventType::Character;
|
||||
event.character = static_cast<std::uint32_t>(wParam);
|
||||
event.modifiers = m_inputModifierTracker.GetCurrentModifiers();
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
void Application::QueueWindowFocusEvent(UIInputEventType type) {
|
||||
UIInputEvent event = {};
|
||||
event.type = type;
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
bool Application::LoadStructuredScreen(const char* triggerReason) {
|
||||
(void)triggerReason;
|
||||
std::string scenarioLoadWarning = {};
|
||||
const EditorValidationScenario* scenario = m_requestedScenarioId.empty()
|
||||
? &GetDefaultEditorValidationScenario()
|
||||
: FindEditorValidationScenario(m_requestedScenarioId);
|
||||
if (scenario == nullptr) {
|
||||
scenario = &GetDefaultEditorValidationScenario();
|
||||
scenarioLoadWarning = "Unknown validation scenario: " + m_requestedScenarioId;
|
||||
}
|
||||
|
||||
m_activeScenario = scenario;
|
||||
m_screenAsset = {};
|
||||
m_screenAsset.screenId = scenario->id;
|
||||
m_screenAsset.documentPath = scenario->documentPath.string();
|
||||
m_screenAsset.themePath = scenario->themePath.string();
|
||||
|
||||
const bool loaded = m_screenPlayer.Load(m_screenAsset);
|
||||
m_useStructuredScreen = loaded;
|
||||
m_runtimeStatus = loaded ? scenario->displayName : "Editor UI Validation | Load Error";
|
||||
m_runtimeError = loaded
|
||||
? scenarioLoadWarning
|
||||
: (scenarioLoadWarning.empty()
|
||||
? m_screenPlayer.GetLastError()
|
||||
: scenarioLoadWarning + " | " + m_screenPlayer.GetLastError());
|
||||
RebuildTrackedFileStates();
|
||||
return loaded;
|
||||
}
|
||||
|
||||
void Application::RefreshStructuredScreen() {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (m_lastReloadPollTime.time_since_epoch().count() != 0 &&
|
||||
now - m_lastReloadPollTime < kReloadPollInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_lastReloadPollTime = now;
|
||||
if (DetectTrackedFileChange()) {
|
||||
LoadStructuredScreen("reload");
|
||||
}
|
||||
}
|
||||
|
||||
void Application::RebuildTrackedFileStates() {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
m_trackedFiles.clear();
|
||||
std::unordered_set<std::string> seenPaths = {};
|
||||
std::error_code errorCode = {};
|
||||
|
||||
auto appendTrackedPath = [&](const std::string& rawPath) {
|
||||
if (rawPath.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fs::path normalizedPath = fs::path(rawPath).lexically_normal();
|
||||
const std::string key = normalizedPath.string();
|
||||
if (!seenPaths.insert(key).second) {
|
||||
return;
|
||||
}
|
||||
|
||||
TrackedFileState state = {};
|
||||
state.path = normalizedPath;
|
||||
state.exists = fs::exists(normalizedPath, errorCode);
|
||||
errorCode.clear();
|
||||
if (state.exists) {
|
||||
state.writeTime = fs::last_write_time(normalizedPath, errorCode);
|
||||
errorCode.clear();
|
||||
}
|
||||
m_trackedFiles.push_back(std::move(state));
|
||||
};
|
||||
|
||||
appendTrackedPath(m_screenAsset.documentPath);
|
||||
appendTrackedPath(m_screenAsset.themePath);
|
||||
|
||||
if (const auto* document = m_screenPlayer.GetDocument(); document != nullptr) {
|
||||
for (const std::string& dependency : document->dependencies) {
|
||||
appendTrackedPath(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Application::DetectTrackedFileChange() const {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
std::error_code errorCode = {};
|
||||
for (const TrackedFileState& trackedFile : m_trackedFiles) {
|
||||
const bool existsNow = fs::exists(trackedFile.path, errorCode);
|
||||
errorCode.clear();
|
||||
if (existsNow != trackedFile.exists) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!existsNow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto writeTimeNow = fs::last_write_time(trackedFile.path, errorCode);
|
||||
errorCode.clear();
|
||||
if (writeTimeNow != trackedFile.writeTime) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float height) const {
|
||||
const bool authoredMode = m_useStructuredScreen && m_screenPlayer.IsLoaded();
|
||||
const float panelWidth = authoredMode ? 460.0f : 360.0f;
|
||||
std::vector<std::string> detailLines = {};
|
||||
detailLines.push_back(
|
||||
authoredMode
|
||||
? "Hot reload watches authored UI resources."
|
||||
: "Authored validation scene failed to load.");
|
||||
if (m_activeScenario != nullptr) {
|
||||
detailLines.push_back("Scenario: " + m_activeScenario->id);
|
||||
}
|
||||
|
||||
if (authoredMode) {
|
||||
const auto& inputDebug = m_documentHost.GetInputDebugSnapshot();
|
||||
detailLines.push_back(
|
||||
"Hover | Focus: " +
|
||||
ExtractStateKeyTail(inputDebug.hoveredStateKey) +
|
||||
" | " +
|
||||
ExtractStateKeyTail(inputDebug.focusedStateKey));
|
||||
detailLines.push_back(
|
||||
"Active | Capture: " +
|
||||
ExtractStateKeyTail(inputDebug.activeStateKey) +
|
||||
" | " +
|
||||
ExtractStateKeyTail(inputDebug.captureStateKey));
|
||||
detailLines.push_back(
|
||||
"Scope W/P/Wg: " +
|
||||
ExtractStateKeyTail(inputDebug.windowScopeStateKey) +
|
||||
" | " +
|
||||
ExtractStateKeyTail(inputDebug.panelScopeStateKey) +
|
||||
" | " +
|
||||
ExtractStateKeyTail(inputDebug.widgetScopeStateKey));
|
||||
detailLines.push_back(
|
||||
std::string("Text input: ") +
|
||||
(inputDebug.textInputActive ? "active" : "idle"));
|
||||
if (!inputDebug.recentShortcutCommandId.empty()) {
|
||||
detailLines.push_back(
|
||||
"Recent shortcut: " +
|
||||
inputDebug.recentShortcutScope +
|
||||
" -> " +
|
||||
inputDebug.recentShortcutCommandId);
|
||||
detailLines.push_back(
|
||||
std::string("Recent shortcut state: ") +
|
||||
(inputDebug.recentShortcutHandled
|
||||
? "handled"
|
||||
: (inputDebug.recentShortcutSuppressed ? "suppressed" : "observed")) +
|
||||
" @ " +
|
||||
ExtractStateKeyTail(inputDebug.recentShortcutOwnerStateKey));
|
||||
} else {
|
||||
detailLines.push_back("Recent shortcut: none");
|
||||
}
|
||||
if (!inputDebug.lastEventType.empty()) {
|
||||
const std::string eventPosition = inputDebug.lastEventType == "KeyDown" ||
|
||||
inputDebug.lastEventType == "KeyUp" ||
|
||||
inputDebug.lastEventType == "Character" ||
|
||||
inputDebug.lastEventType == "FocusGained" ||
|
||||
inputDebug.lastEventType == "FocusLost"
|
||||
? std::string()
|
||||
: " at " + FormatPoint(inputDebug.pointerPosition);
|
||||
detailLines.push_back(
|
||||
"Last input: " +
|
||||
inputDebug.lastEventType +
|
||||
eventPosition);
|
||||
detailLines.push_back(
|
||||
"Route: " +
|
||||
inputDebug.lastTargetKind +
|
||||
" -> " +
|
||||
ExtractStateKeyTail(inputDebug.lastTargetStateKey));
|
||||
if (!inputDebug.lastShortcutCommandId.empty()) {
|
||||
detailLines.push_back(
|
||||
"Shortcut: " +
|
||||
inputDebug.lastShortcutScope +
|
||||
" -> " +
|
||||
inputDebug.lastShortcutCommandId);
|
||||
detailLines.push_back(
|
||||
std::string("Shortcut state: ") +
|
||||
(inputDebug.lastShortcutHandled
|
||||
? "handled"
|
||||
: (inputDebug.lastShortcutSuppressed ? "suppressed" : "observed")) +
|
||||
" @ " +
|
||||
ExtractStateKeyTail(inputDebug.lastShortcutOwnerStateKey));
|
||||
}
|
||||
detailLines.push_back(
|
||||
"Last event result: " +
|
||||
(inputDebug.lastResult.empty() ? std::string("n/a") : inputDebug.lastResult));
|
||||
}
|
||||
}
|
||||
|
||||
if (m_autoScreenshot.HasPendingCapture()) {
|
||||
detailLines.push_back("Shot pending...");
|
||||
} else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) {
|
||||
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureSummary(), 78u));
|
||||
} else {
|
||||
detailLines.push_back("Screenshots: F12 -> current scenario captures/");
|
||||
}
|
||||
|
||||
if (!m_runtimeError.empty()) {
|
||||
detailLines.push_back(TruncateText(m_runtimeError, 78u));
|
||||
} else if (!m_autoScreenshot.GetLastCaptureError().empty()) {
|
||||
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureError(), 78u));
|
||||
} else if (!authoredMode) {
|
||||
detailLines.push_back("No fallback sandbox is rendered in this host.");
|
||||
}
|
||||
|
||||
const float panelHeight = 38.0f + static_cast<float>(detailLines.size()) * 18.0f;
|
||||
const UIRect panelRect(width - panelWidth - 16.0f, height - panelHeight - 42.0f, panelWidth, panelHeight);
|
||||
|
||||
UIDrawList& overlay = drawData.EmplaceDrawList("Editor UI Validation Overlay");
|
||||
overlay.AddFilledRect(panelRect, kOverlayBgColor, 10.0f);
|
||||
overlay.AddRectOutline(panelRect, kOverlayBorderColor, 1.0f, 10.0f);
|
||||
overlay.AddFilledRect(
|
||||
UIRect(panelRect.x + 12.0f, panelRect.y + 14.0f, 8.0f, 8.0f),
|
||||
authoredMode ? kOverlaySuccess : kOverlayFallback,
|
||||
4.0f);
|
||||
overlay.AddText(
|
||||
UIPoint(panelRect.x + 28.0f, panelRect.y + 10.0f),
|
||||
m_runtimeStatus.empty() ? "Editor UI Validation" : m_runtimeStatus,
|
||||
kOverlayTextPrimary,
|
||||
14.0f);
|
||||
|
||||
float detailY = panelRect.y + 30.0f;
|
||||
for (std::size_t index = 0; index < detailLines.size(); ++index) {
|
||||
const bool lastLine = index + 1u == detailLines.size();
|
||||
overlay.AddText(
|
||||
UIPoint(panelRect.x + 28.0f, detailY),
|
||||
detailLines[index],
|
||||
lastLine && (!m_runtimeError.empty() || !m_autoScreenshot.GetLastCaptureError().empty())
|
||||
? kOverlayFallback
|
||||
: kOverlayTextMuted,
|
||||
12.0f);
|
||||
detailY += 18.0f;
|
||||
}
|
||||
}
|
||||
|
||||
std::filesystem::path Application::ResolveRepoRelativePath(const char* relativePath) {
|
||||
return (GetRepoRootPath() / relativePath).lexically_normal();
|
||||
}
|
||||
|
||||
LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
|
||||
if (message == WM_NCCREATE) {
|
||||
const auto* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
|
||||
auto* application = reinterpret_cast<Application*>(createStruct->lpCreateParams);
|
||||
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(application));
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
Application* application = GetApplicationFromWindow(hwnd);
|
||||
switch (message) {
|
||||
case WM_SIZE:
|
||||
if (application != nullptr && wParam != SIZE_MINIMIZED) {
|
||||
application->OnResize(static_cast<UINT>(LOWORD(lParam)), static_cast<UINT>(HIWORD(lParam)));
|
||||
}
|
||||
return 0;
|
||||
case WM_PAINT:
|
||||
if (application != nullptr) {
|
||||
PAINTSTRUCT paintStruct = {};
|
||||
BeginPaint(hwnd, &paintStruct);
|
||||
application->RenderFrame();
|
||||
EndPaint(hwnd, &paintStruct);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_MOUSEMOVE:
|
||||
if (application != nullptr) {
|
||||
if (!application->m_trackingMouseLeave) {
|
||||
TRACKMOUSEEVENT trackMouseEvent = {};
|
||||
trackMouseEvent.cbSize = sizeof(trackMouseEvent);
|
||||
trackMouseEvent.dwFlags = TME_LEAVE;
|
||||
trackMouseEvent.hwndTrack = hwnd;
|
||||
if (TrackMouseEvent(&trackMouseEvent)) {
|
||||
application->m_trackingMouseLeave = true;
|
||||
}
|
||||
}
|
||||
application->QueuePointerEvent(UIInputEventType::PointerMove, UIPointerButton::None, wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_MOUSELEAVE:
|
||||
if (application != nullptr) {
|
||||
application->m_trackingMouseLeave = false;
|
||||
application->QueuePointerLeaveEvent();
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_LBUTTONDOWN:
|
||||
if (application != nullptr) {
|
||||
SetFocus(hwnd);
|
||||
SetCapture(hwnd);
|
||||
application->QueuePointerEvent(UIInputEventType::PointerButtonDown, UIPointerButton::Left, wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_LBUTTONUP:
|
||||
if (application != nullptr) {
|
||||
if (GetCapture() == hwnd) {
|
||||
ReleaseCapture();
|
||||
}
|
||||
application->QueuePointerEvent(UIInputEventType::PointerButtonUp, UIPointerButton::Left, wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_MOUSEWHEEL:
|
||||
if (application != nullptr) {
|
||||
application->QueuePointerWheelEvent(GET_WHEEL_DELTA_WPARAM(wParam), wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_SETFOCUS:
|
||||
if (application != nullptr) {
|
||||
application->m_inputModifierTracker.SyncFromSystemState();
|
||||
application->QueueWindowFocusEvent(UIInputEventType::FocusGained);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_KILLFOCUS:
|
||||
if (application != nullptr) {
|
||||
application->m_inputModifierTracker.Reset();
|
||||
application->QueueWindowFocusEvent(UIInputEventType::FocusLost);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_KEYDOWN:
|
||||
case WM_SYSKEYDOWN:
|
||||
if (application != nullptr) {
|
||||
if (wParam == VK_F12) {
|
||||
application->m_autoScreenshot.RequestCapture("manual_f12");
|
||||
}
|
||||
application->QueueKeyEvent(UIInputEventType::KeyDown, wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_KEYUP:
|
||||
case WM_SYSKEYUP:
|
||||
if (application != nullptr) {
|
||||
application->QueueKeyEvent(UIInputEventType::KeyUp, wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_CHAR:
|
||||
if (application != nullptr) {
|
||||
application->QueueCharacterEvent(wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_ERASEBKGND:
|
||||
return 1;
|
||||
case WM_DESTROY:
|
||||
if (application != nullptr) {
|
||||
application->m_hwnd = nullptr;
|
||||
}
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return DefWindowProcW(hwnd, message, wParam, lParam);
|
||||
}
|
||||
|
||||
int RunEditorUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId) {
|
||||
Application application(std::move(requestedScenarioId));
|
||||
return application.Run(hInstance, nCmdShow);
|
||||
}
|
||||
|
||||
} // namespace XCEngine::Tests::EditorUI
|
||||
83
tests/UI/Editor/integration/shared/src/Application.h
Normal file
83
tests/UI/Editor/integration/shared/src/Application.h
Normal file
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
|
||||
#include "EditorValidationScenario.h"
|
||||
#include <XCNewEditor/Host/AutoScreenshot.h>
|
||||
#include <XCNewEditor/Host/InputModifierTracker.h>
|
||||
#include <XCNewEditor/Host/NativeRenderer.h>
|
||||
|
||||
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
|
||||
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
|
||||
|
||||
#include <windows.h>
|
||||
#include <windowsx.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace XCEngine::Tests::EditorUI {
|
||||
|
||||
class Application {
|
||||
public:
|
||||
explicit Application(std::string requestedScenarioId = {});
|
||||
|
||||
int Run(HINSTANCE hInstance, int nCmdShow);
|
||||
|
||||
private:
|
||||
struct TrackedFileState {
|
||||
std::filesystem::path path = {};
|
||||
std::filesystem::file_time_type writeTime = {};
|
||||
bool exists = false;
|
||||
};
|
||||
|
||||
static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
|
||||
|
||||
bool Initialize(HINSTANCE hInstance, int nCmdShow);
|
||||
void Shutdown();
|
||||
void RenderFrame();
|
||||
void OnResize(UINT width, UINT height);
|
||||
void QueuePointerEvent(::XCEngine::UI::UIInputEventType type, ::XCEngine::UI::UIPointerButton button, WPARAM wParam, LPARAM lParam);
|
||||
void QueuePointerLeaveEvent();
|
||||
void QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam);
|
||||
void QueueKeyEvent(::XCEngine::UI::UIInputEventType type, WPARAM wParam, LPARAM lParam);
|
||||
void QueueCharacterEvent(WPARAM wParam, LPARAM lParam);
|
||||
void QueueWindowFocusEvent(::XCEngine::UI::UIInputEventType type);
|
||||
bool LoadStructuredScreen(const char* triggerReason);
|
||||
void RefreshStructuredScreen();
|
||||
void RebuildTrackedFileStates();
|
||||
bool DetectTrackedFileChange() const;
|
||||
void AppendRuntimeOverlay(::XCEngine::UI::UIDrawData& drawData, float width, float height) const;
|
||||
static std::filesystem::path ResolveRepoRelativePath(const char* relativePath);
|
||||
|
||||
HWND m_hwnd = nullptr;
|
||||
HINSTANCE m_hInstance = nullptr;
|
||||
ATOM m_windowClassAtom = 0;
|
||||
::XCEngine::XCUI::Host::NativeRenderer m_renderer;
|
||||
::XCEngine::XCUI::Host::AutoScreenshotController m_autoScreenshot;
|
||||
::XCEngine::UI::Runtime::UIDocumentScreenHost m_documentHost;
|
||||
::XCEngine::UI::Runtime::UIScreenPlayer m_screenPlayer;
|
||||
::XCEngine::UI::Runtime::UIScreenAsset m_screenAsset = {};
|
||||
const EditorValidationScenario* m_activeScenario = nullptr;
|
||||
std::string m_requestedScenarioId = {};
|
||||
std::vector<TrackedFileState> m_trackedFiles = {};
|
||||
std::chrono::steady_clock::time_point m_startTime = {};
|
||||
std::chrono::steady_clock::time_point m_lastFrameTime = {};
|
||||
std::chrono::steady_clock::time_point m_lastReloadPollTime = {};
|
||||
std::uint64_t m_frameIndex = 0;
|
||||
std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {};
|
||||
::XCEngine::XCUI::Host::InputModifierTracker m_inputModifierTracker = {};
|
||||
bool m_trackingMouseLeave = false;
|
||||
bool m_useStructuredScreen = false;
|
||||
std::string m_runtimeStatus = {};
|
||||
std::string m_runtimeError = {};
|
||||
};
|
||||
|
||||
int RunEditorUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId = {});
|
||||
|
||||
} // namespace XCEngine::Tests::EditorUI
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "EditorValidationScenario.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
|
||||
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
|
||||
#endif
|
||||
|
||||
namespace XCEngine::Tests::EditorUI {
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
fs::path RepoRootPath() {
|
||||
std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT;
|
||||
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
|
||||
root = root.substr(1u, root.size() - 2u);
|
||||
}
|
||||
return fs::path(root).lexically_normal();
|
||||
}
|
||||
|
||||
fs::path RepoRelative(const char* relativePath) {
|
||||
return (RepoRootPath() / relativePath).lexically_normal();
|
||||
}
|
||||
|
||||
const std::array<EditorValidationScenario, 4>& GetEditorValidationScenarios() {
|
||||
static const std::array<EditorValidationScenario, 4> scenarios = { {
|
||||
{
|
||||
"editor.input.keyboard_focus",
|
||||
UIValidationDomain::Editor,
|
||||
"input",
|
||||
"Editor Input | Keyboard Focus",
|
||||
RepoRelative("tests/UI/Editor/integration/input/keyboard_focus/View.xcui"),
|
||||
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
|
||||
RepoRelative("tests/UI/Editor/integration/input/keyboard_focus/captures")
|
||||
},
|
||||
{
|
||||
"editor.input.pointer_states",
|
||||
UIValidationDomain::Editor,
|
||||
"input",
|
||||
"Editor Input | Pointer States",
|
||||
RepoRelative("tests/UI/Editor/integration/input/pointer_states/View.xcui"),
|
||||
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
|
||||
RepoRelative("tests/UI/Editor/integration/input/pointer_states/captures")
|
||||
},
|
||||
{
|
||||
"editor.input.shortcut_scope",
|
||||
UIValidationDomain::Editor,
|
||||
"input",
|
||||
"Editor Input | Shortcut Scope",
|
||||
RepoRelative("tests/UI/Editor/integration/input/shortcut_scope/View.xcui"),
|
||||
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
|
||||
RepoRelative("tests/UI/Editor/integration/input/shortcut_scope/captures")
|
||||
},
|
||||
{
|
||||
"editor.layout.splitter_resize",
|
||||
UIValidationDomain::Editor,
|
||||
"layout",
|
||||
"Editor Layout | Splitter Resize",
|
||||
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/View.xcui"),
|
||||
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
|
||||
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/captures")
|
||||
}
|
||||
} };
|
||||
return scenarios;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const EditorValidationScenario& GetDefaultEditorValidationScenario() {
|
||||
return GetEditorValidationScenarios().front();
|
||||
}
|
||||
|
||||
const EditorValidationScenario* FindEditorValidationScenario(std::string_view id) {
|
||||
for (const EditorValidationScenario& scenario : GetEditorValidationScenarios()) {
|
||||
if (scenario.id == id) {
|
||||
return &scenario;
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace XCEngine::Tests::EditorUI
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace XCEngine::Tests::EditorUI {
|
||||
|
||||
enum class UIValidationDomain : unsigned char {
|
||||
Editor = 0,
|
||||
Runtime
|
||||
};
|
||||
|
||||
struct EditorValidationScenario {
|
||||
std::string id = {};
|
||||
UIValidationDomain domain = UIValidationDomain::Editor;
|
||||
std::string categoryId = {};
|
||||
std::string displayName = {};
|
||||
std::filesystem::path documentPath = {};
|
||||
std::filesystem::path themePath = {};
|
||||
std::filesystem::path captureRootPath = {};
|
||||
};
|
||||
|
||||
const EditorValidationScenario& GetDefaultEditorValidationScenario();
|
||||
const EditorValidationScenario* FindEditorValidationScenario(std::string_view id);
|
||||
|
||||
} // namespace XCEngine::Tests::EditorUI
|
||||
@@ -0,0 +1,32 @@
|
||||
<Theme name="EditorValidationTheme">
|
||||
<Tokens>
|
||||
<Color name="color.bg.workspace" value="#1C1C1C" />
|
||||
<Color name="color.bg.panel" value="#292929" />
|
||||
<Color name="color.bg.accent" value="#3A3A3A" />
|
||||
<Color name="color.bg.selection" value="#4A4A4A" />
|
||||
<Color name="color.text.primary" value="#EEEEEE" />
|
||||
<Color name="color.text.muted" value="#B0B0B0" />
|
||||
<Spacing name="space.panel" value="12" />
|
||||
<Spacing name="space.shell" value="18" />
|
||||
<Radius name="radius.panel" value="10" />
|
||||
<Radius name="radius.control" value="8" />
|
||||
</Tokens>
|
||||
|
||||
<Widgets>
|
||||
<Widget type="View" style="EditorWorkspace">
|
||||
<Property name="background" value="color.bg.workspace" />
|
||||
<Property name="padding" value="space.shell" />
|
||||
</Widget>
|
||||
|
||||
<Widget type="Card" style="EditorPanel">
|
||||
<Property name="background" value="color.bg.panel" />
|
||||
<Property name="radius" value="radius.panel" />
|
||||
<Property name="padding" value="space.panel" />
|
||||
</Widget>
|
||||
|
||||
<Widget type="Button" style="EditorChip">
|
||||
<Property name="background" value="color.bg.selection" />
|
||||
<Property name="radius" value="radius.control" />
|
||||
</Widget>
|
||||
</Widgets>
|
||||
</Theme>
|
||||
35
tests/UI/Editor/unit/CMakeLists.txt
Normal file
35
tests/UI/Editor/unit/CMakeLists.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
set(EDITOR_UI_UNIT_TEST_SOURCES
|
||||
test_input_modifier_tracker.cpp
|
||||
test_editor_validation_registry.cpp
|
||||
test_structured_editor_shell.cpp
|
||||
# Migration bridge: editor-facing XCUI primitive tests still reuse the
|
||||
# legacy source location until they are relocated under tests/UI/Editor/unit.
|
||||
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_editor_collection_primitives.cpp
|
||||
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_editor_panel_chrome.cpp
|
||||
)
|
||||
|
||||
add_executable(editor_ui_tests ${EDITOR_UI_UNIT_TEST_SOURCES})
|
||||
|
||||
target_link_libraries(editor_ui_tests
|
||||
PRIVATE
|
||||
editor_ui_validation_registry
|
||||
XCNewEditorLib
|
||||
GTest::gtest_main
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_tests
|
||||
PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/new_editor/include
|
||||
${CMAKE_SOURCE_DIR}/new_editor/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_tests PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_tests PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(editor_ui_tests)
|
||||
48
tests/UI/Editor/unit/test_editor_validation_registry.cpp
Normal file
48
tests/UI/Editor/unit/test_editor_validation_registry.cpp
Normal file
@@ -0,0 +1,48 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "EditorValidationScenario.h"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::Tests::EditorUI::FindEditorValidationScenario;
|
||||
using XCEngine::Tests::EditorUI::GetDefaultEditorValidationScenario;
|
||||
using XCEngine::Tests::EditorUI::UIValidationDomain;
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExistingResources) {
|
||||
const auto* pointerScenario = FindEditorValidationScenario("editor.input.pointer_states");
|
||||
const auto* keyboardScenario = FindEditorValidationScenario("editor.input.keyboard_focus");
|
||||
const auto* shortcutScenario = FindEditorValidationScenario("editor.input.shortcut_scope");
|
||||
const auto* splitterScenario = FindEditorValidationScenario("editor.layout.splitter_resize");
|
||||
|
||||
ASSERT_NE(pointerScenario, nullptr);
|
||||
ASSERT_NE(keyboardScenario, nullptr);
|
||||
ASSERT_NE(shortcutScenario, nullptr);
|
||||
ASSERT_NE(splitterScenario, nullptr);
|
||||
EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Editor);
|
||||
EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Editor);
|
||||
EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Editor);
|
||||
EXPECT_EQ(splitterScenario->domain, UIValidationDomain::Editor);
|
||||
EXPECT_EQ(pointerScenario->categoryId, "input");
|
||||
EXPECT_EQ(keyboardScenario->categoryId, "input");
|
||||
EXPECT_EQ(shortcutScenario->categoryId, "input");
|
||||
EXPECT_EQ(splitterScenario->categoryId, "layout");
|
||||
EXPECT_TRUE(std::filesystem::exists(pointerScenario->documentPath));
|
||||
EXPECT_TRUE(std::filesystem::exists(pointerScenario->themePath));
|
||||
EXPECT_TRUE(std::filesystem::exists(keyboardScenario->documentPath));
|
||||
EXPECT_TRUE(std::filesystem::exists(keyboardScenario->themePath));
|
||||
EXPECT_TRUE(std::filesystem::exists(shortcutScenario->documentPath));
|
||||
EXPECT_TRUE(std::filesystem::exists(shortcutScenario->themePath));
|
||||
EXPECT_TRUE(std::filesystem::exists(splitterScenario->documentPath));
|
||||
EXPECT_TRUE(std::filesystem::exists(splitterScenario->themePath));
|
||||
}
|
||||
|
||||
TEST(EditorValidationRegistryTest, DefaultScenarioPointsToKeyboardFocusBatch) {
|
||||
const auto& scenario = GetDefaultEditorValidationScenario();
|
||||
EXPECT_EQ(scenario.id, "editor.input.keyboard_focus");
|
||||
EXPECT_EQ(scenario.domain, UIValidationDomain::Editor);
|
||||
EXPECT_TRUE(std::filesystem::exists(scenario.documentPath));
|
||||
}
|
||||
90
tests/UI/Editor/unit/test_input_modifier_tracker.cpp
Normal file
90
tests/UI/Editor/unit/test_input_modifier_tracker.cpp
Normal file
@@ -0,0 +1,90 @@
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <XCNewEditor/Host/InputModifierTracker.h>
|
||||
|
||||
#include <XCEngine/UI/Types.h>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::XCUI::Host::InputModifierTracker;
|
||||
using XCEngine::UI::UIInputEventType;
|
||||
|
||||
TEST(InputModifierTrackerTest, ControlStatePersistsAcrossChordKeyDownAndClearsOnKeyUp) {
|
||||
InputModifierTracker tracker = {};
|
||||
|
||||
const auto ctrlDown = tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
VK_CONTROL,
|
||||
0x001D0001);
|
||||
EXPECT_TRUE(ctrlDown.control);
|
||||
EXPECT_FALSE(ctrlDown.shift);
|
||||
EXPECT_FALSE(ctrlDown.alt);
|
||||
|
||||
const auto chordKeyDown = tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
'P',
|
||||
0x00190001);
|
||||
EXPECT_TRUE(chordKeyDown.control);
|
||||
|
||||
const auto ctrlUp = tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyUp,
|
||||
VK_CONTROL,
|
||||
static_cast<LPARAM>(0xC01D0001u));
|
||||
EXPECT_FALSE(ctrlUp.control);
|
||||
|
||||
const auto nextKeyDown = tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
'P',
|
||||
0x00190001);
|
||||
EXPECT_FALSE(nextKeyDown.control);
|
||||
}
|
||||
|
||||
TEST(InputModifierTrackerTest, PointerModifiersMergeMouseFlagsWithTrackedKeyboardState) {
|
||||
InputModifierTracker tracker = {};
|
||||
tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
VK_MENU,
|
||||
0x00380001);
|
||||
|
||||
const auto modifiers = tracker.BuildPointerModifiers(MK_SHIFT);
|
||||
EXPECT_TRUE(modifiers.shift);
|
||||
EXPECT_TRUE(modifiers.alt);
|
||||
EXPECT_FALSE(modifiers.control);
|
||||
EXPECT_FALSE(modifiers.super);
|
||||
}
|
||||
|
||||
TEST(InputModifierTrackerTest, RightControlIsTrackedIndependentlyFromLeftControl) {
|
||||
InputModifierTracker tracker = {};
|
||||
|
||||
tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
VK_CONTROL,
|
||||
static_cast<LPARAM>(0x011D0001u));
|
||||
EXPECT_TRUE(tracker.GetCurrentModifiers().control);
|
||||
|
||||
tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
VK_CONTROL,
|
||||
0x001D0001);
|
||||
EXPECT_TRUE(tracker.GetCurrentModifiers().control);
|
||||
|
||||
tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyUp,
|
||||
VK_CONTROL,
|
||||
static_cast<LPARAM>(0xC11D0001u));
|
||||
EXPECT_TRUE(tracker.GetCurrentModifiers().control);
|
||||
|
||||
tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyUp,
|
||||
VK_CONTROL,
|
||||
static_cast<LPARAM>(0xC01D0001u));
|
||||
EXPECT_FALSE(tracker.GetCurrentModifiers().control);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
93
tests/UI/Editor/unit/test_structured_editor_shell.cpp
Normal file
93
tests/UI/Editor/unit/test_structured_editor_shell.cpp
Normal file
@@ -0,0 +1,93 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "editor/EditorShellAsset.h"
|
||||
|
||||
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
|
||||
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifndef XCNEWEDITOR_REPO_ROOT
|
||||
#define XCNEWEDITOR_REPO_ROOT "."
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::NewEditor::BuildDefaultEditorShellAsset;
|
||||
using XCEngine::UI::UIDrawCommand;
|
||||
using XCEngine::UI::UIDrawCommandType;
|
||||
using XCEngine::UI::UIDrawData;
|
||||
using XCEngine::UI::Runtime::UIScreenAsset;
|
||||
using XCEngine::UI::Runtime::UIScreenFrameInput;
|
||||
using XCEngine::UI::Runtime::UIScreenPlayer;
|
||||
using XCEngine::UI::Runtime::UIDocumentScreenHost;
|
||||
|
||||
std::filesystem::path RepoRootPath() {
|
||||
std::string root = XCNEWEDITOR_REPO_ROOT;
|
||||
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
|
||||
root = root.substr(1u, root.size() - 2u);
|
||||
}
|
||||
return std::filesystem::path(root).lexically_normal();
|
||||
}
|
||||
|
||||
bool DrawDataContainsText(const UIDrawData& drawData, const std::string& text) {
|
||||
for (const auto& drawList : drawData.GetDrawLists()) {
|
||||
for (const UIDrawCommand& command : drawList.GetCommands()) {
|
||||
if (command.type == UIDrawCommandType::Text && command.text == text) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ContainsPathWithFilename(
|
||||
const std::vector<std::string>& paths,
|
||||
const char* expectedFileName) {
|
||||
for (const std::string& path : paths) {
|
||||
if (std::filesystem::path(path).filename() == expectedFileName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(EditorUIStructuredShellTest, AuthoredEditorShellLoadsFromRepositoryResources) {
|
||||
const auto shell = BuildDefaultEditorShellAsset(RepoRootPath());
|
||||
|
||||
ASSERT_TRUE(std::filesystem::exists(shell.documentPath));
|
||||
ASSERT_TRUE(std::filesystem::exists(shell.themePath));
|
||||
|
||||
UIScreenAsset asset = {};
|
||||
asset.screenId = shell.screenId;
|
||||
asset.documentPath = shell.documentPath.string();
|
||||
asset.themePath = shell.themePath.string();
|
||||
|
||||
UIDocumentScreenHost host = {};
|
||||
UIScreenPlayer player(host);
|
||||
|
||||
ASSERT_TRUE(player.Load(asset)) << player.GetLastError();
|
||||
ASSERT_NE(player.GetDocument(), nullptr);
|
||||
EXPECT_TRUE(player.GetDocument()->hasThemeDocument);
|
||||
EXPECT_TRUE(ContainsPathWithFilename(player.GetDocument()->dependencies, "editor_shell.xctheme"));
|
||||
|
||||
UIScreenFrameInput input = {};
|
||||
input.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 1440.0f, 900.0f);
|
||||
input.frameIndex = 1u;
|
||||
input.focused = true;
|
||||
|
||||
const auto& frame = player.Update(input);
|
||||
EXPECT_TRUE(frame.stats.documentLoaded);
|
||||
EXPECT_GE(frame.stats.nodeCount, 2u);
|
||||
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "XCUI Editor Layer"));
|
||||
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Left Pane Host"));
|
||||
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Primary Workspace Host"));
|
||||
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Right Pane Host"));
|
||||
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Bottom Pane Host"));
|
||||
}
|
||||
17
tests/UI/Runtime/CMakeLists.txt
Normal file
17
tests/UI/Runtime/CMakeLists.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
cmake_minimum_required(VERSION 3.15)
|
||||
|
||||
project(XCEngine_RuntimeUITests)
|
||||
|
||||
add_subdirectory(unit)
|
||||
add_subdirectory(integration)
|
||||
|
||||
add_custom_target(runtime_ui_unit_tests
|
||||
DEPENDS
|
||||
runtime_ui_tests
|
||||
)
|
||||
|
||||
add_custom_target(runtime_ui_all_tests
|
||||
DEPENDS
|
||||
runtime_ui_unit_tests
|
||||
runtime_ui_integration_tests
|
||||
)
|
||||
1
tests/UI/Runtime/integration/CMakeLists.txt
Normal file
1
tests/UI/Runtime/integration/CMakeLists.txt
Normal file
@@ -0,0 +1 @@
|
||||
add_custom_target(runtime_ui_integration_tests)
|
||||
11
tests/UI/Runtime/integration/README.md
Normal file
11
tests/UI/Runtime/integration/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Runtime UI Integration Validation
|
||||
|
||||
This directory is reserved for interactive XCUI runtime validation apps.
|
||||
|
||||
Planned scope:
|
||||
|
||||
- HUD and menu layer-stack validation
|
||||
- Runtime input routing and blocking rules
|
||||
- Screen stack and modal transitions
|
||||
|
||||
For now the runtime UI lane only has automated unit coverage in `tests/UI/Runtime/unit/`.
|
||||
28
tests/UI/Runtime/unit/CMakeLists.txt
Normal file
28
tests/UI/Runtime/unit/CMakeLists.txt
Normal file
@@ -0,0 +1,28 @@
|
||||
set(RUNTIME_UI_TEST_SOURCES
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_shortcut_scope.cpp
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_splitter_validation.cpp
|
||||
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_runtime.cpp
|
||||
)
|
||||
|
||||
add_executable(runtime_ui_tests ${RUNTIME_UI_TEST_SOURCES})
|
||||
|
||||
if(MSVC)
|
||||
set_target_properties(runtime_ui_tests PROPERTIES
|
||||
LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib"
|
||||
)
|
||||
endif()
|
||||
|
||||
target_link_libraries(runtime_ui_tests
|
||||
PRIVATE
|
||||
XCEngine
|
||||
GTest::gtest
|
||||
GTest::gtest_main
|
||||
)
|
||||
|
||||
target_include_directories(runtime_ui_tests PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
${CMAKE_SOURCE_DIR}/tests/Fixtures
|
||||
)
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(runtime_ui_tests)
|
||||
305
tests/UI/Runtime/unit/test_ui_runtime_shortcut_scope.cpp
Normal file
305
tests/UI/Runtime/unit/test_ui_runtime_shortcut_scope.cpp
Normal file
@@ -0,0 +1,305 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <XCEngine/Input/InputTypes.h>
|
||||
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
|
||||
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::Input::KeyCode;
|
||||
using XCEngine::UI::UIDrawCommand;
|
||||
using XCEngine::UI::UIDrawCommandType;
|
||||
using XCEngine::UI::UIPoint;
|
||||
using XCEngine::UI::UIRect;
|
||||
using XCEngine::UI::Runtime::UIScreenAsset;
|
||||
using XCEngine::UI::Runtime::UIScreenFrameInput;
|
||||
using XCEngine::UI::Runtime::UIScreenPlayer;
|
||||
using XCEngine::UI::Runtime::UIDocumentScreenHost;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
class TempFileScope {
|
||||
public:
|
||||
TempFileScope(std::string stem, std::string extension, std::string contents) {
|
||||
const auto uniqueId = std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count());
|
||||
m_path = fs::temp_directory_path() / (std::move(stem) + "_" + uniqueId + std::move(extension));
|
||||
std::ofstream output(m_path, std::ios::binary | std::ios::trunc);
|
||||
output << contents;
|
||||
}
|
||||
|
||||
~TempFileScope() {
|
||||
std::error_code ec;
|
||||
fs::remove(m_path, ec);
|
||||
}
|
||||
|
||||
const fs::path& Path() const {
|
||||
return m_path;
|
||||
}
|
||||
|
||||
private:
|
||||
fs::path m_path = {};
|
||||
};
|
||||
|
||||
UIScreenAsset BuildScreenAsset(const fs::path& viewPath, const char* screenId) {
|
||||
UIScreenAsset screen = {};
|
||||
screen.screenId = screenId;
|
||||
screen.documentPath = viewPath.string();
|
||||
return screen;
|
||||
}
|
||||
|
||||
UIScreenFrameInput BuildInputState(std::uint64_t frameIndex = 1u) {
|
||||
UIScreenFrameInput input = {};
|
||||
input.viewportRect = UIRect(0.0f, 0.0f, 960.0f, 720.0f);
|
||||
input.frameIndex = frameIndex;
|
||||
input.focused = true;
|
||||
return input;
|
||||
}
|
||||
|
||||
const UIDrawCommand* FindTextCommand(
|
||||
const XCEngine::UI::UIDrawData& drawData,
|
||||
const std::string& text) {
|
||||
for (const auto& drawList : drawData.GetDrawLists()) {
|
||||
for (const UIDrawCommand& command : drawList.GetCommands()) {
|
||||
if (command.type == UIDrawCommandType::Text && command.text == text) {
|
||||
return &command;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool RectContainsPoint(
|
||||
const UIRect& rect,
|
||||
const UIPoint& point) {
|
||||
return point.x >= rect.x &&
|
||||
point.x <= rect.x + rect.width &&
|
||||
point.y >= rect.y &&
|
||||
point.y <= rect.y + rect.height;
|
||||
}
|
||||
|
||||
bool TryFindSmallestFilledRectContainingPoint(
|
||||
const XCEngine::UI::UIDrawData& drawData,
|
||||
const UIPoint& point,
|
||||
UIRect& outRect) {
|
||||
bool found = false;
|
||||
float bestArea = (std::numeric_limits<float>::max)();
|
||||
|
||||
for (const auto& drawList : drawData.GetDrawLists()) {
|
||||
for (const UIDrawCommand& command : drawList.GetCommands()) {
|
||||
if (command.type != UIDrawCommandType::FilledRect ||
|
||||
!RectContainsPoint(command.rect, point)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const float area = command.rect.width * command.rect.height;
|
||||
if (!found || area < bestArea) {
|
||||
outRect = command.rect;
|
||||
bestArea = area;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
bool TryFindFilledRectForText(
|
||||
const XCEngine::UI::UIDrawData& drawData,
|
||||
const std::string& text,
|
||||
UIRect& outRect) {
|
||||
const auto* textCommand = FindTextCommand(drawData, text);
|
||||
return textCommand != nullptr &&
|
||||
TryFindSmallestFilledRectContainingPoint(drawData, textCommand->position, outRect);
|
||||
}
|
||||
|
||||
UIPoint GetRectCenter(const UIRect& rect) {
|
||||
return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f);
|
||||
}
|
||||
|
||||
void FocusButton(
|
||||
UIScreenPlayer& player,
|
||||
const UIRect& viewportRect,
|
||||
std::uint64_t& frameIndex,
|
||||
const UIPoint& point) {
|
||||
UIScreenFrameInput downInput = BuildInputState(frameIndex++);
|
||||
downInput.viewportRect = viewportRect;
|
||||
XCEngine::UI::UIInputEvent pointerDown = {};
|
||||
pointerDown.type = XCEngine::UI::UIInputEventType::PointerButtonDown;
|
||||
pointerDown.pointerButton = XCEngine::UI::UIPointerButton::Left;
|
||||
pointerDown.position = point;
|
||||
downInput.events.push_back(pointerDown);
|
||||
player.Update(downInput);
|
||||
|
||||
UIScreenFrameInput upInput = BuildInputState(frameIndex++);
|
||||
upInput.viewportRect = viewportRect;
|
||||
XCEngine::UI::UIInputEvent pointerUp = {};
|
||||
pointerUp.type = XCEngine::UI::UIInputEventType::PointerButtonUp;
|
||||
pointerUp.pointerButton = XCEngine::UI::UIPointerButton::Left;
|
||||
pointerUp.position = point;
|
||||
upInput.events.push_back(pointerUp);
|
||||
player.Update(upInput);
|
||||
}
|
||||
|
||||
void SendCtrlP(
|
||||
UIScreenPlayer& player,
|
||||
const UIRect& viewportRect,
|
||||
std::uint64_t& frameIndex) {
|
||||
UIScreenFrameInput input = BuildInputState(frameIndex++);
|
||||
input.viewportRect = viewportRect;
|
||||
XCEngine::UI::UIInputEvent event = {};
|
||||
event.type = XCEngine::UI::UIInputEventType::KeyDown;
|
||||
event.keyCode = static_cast<std::int32_t>(KeyCode::P);
|
||||
event.modifiers.control = true;
|
||||
input.events.push_back(event);
|
||||
player.Update(input);
|
||||
}
|
||||
|
||||
void SendTab(
|
||||
UIScreenPlayer& player,
|
||||
const UIRect& viewportRect,
|
||||
std::uint64_t& frameIndex) {
|
||||
UIScreenFrameInput input = BuildInputState(frameIndex++);
|
||||
input.viewportRect = viewportRect;
|
||||
XCEngine::UI::UIInputEvent event = {};
|
||||
event.type = XCEngine::UI::UIInputEventType::KeyDown;
|
||||
event.keyCode = static_cast<std::int32_t>(KeyCode::Tab);
|
||||
input.events.push_back(event);
|
||||
player.Update(input);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(UIRuntimeShortcutScopeTest, DocumentHostRoutesFocusedShortcutThroughWidgetPanelWindowAndGlobalScopes) {
|
||||
TempFileScope viewFile(
|
||||
"xcui_runtime_shortcut_scope",
|
||||
".xcui",
|
||||
"<View name=\"Shortcut Scope Test\" shortcut=\"Ctrl+P\" shortcutCommand=\"global.command\" shortcutScope=\"global\">\n"
|
||||
" <Column id=\"root\" padding=\"18\" gap=\"12\">\n"
|
||||
" <Button id=\"global-focus\" text=\"Global Focus\" />\n"
|
||||
" <Card id=\"window-shell\" title=\"Window Scope\" shortcutScopeRoot=\"window\" shortcut=\"Ctrl+P\" shortcutCommand=\"window.command\" shortcutScope=\"window\">\n"
|
||||
" <Column gap=\"10\">\n"
|
||||
" <Button id=\"window-focus\" text=\"Window Focus\" />\n"
|
||||
" <Card id=\"panel-shell\" title=\"Panel Scope\" shortcutScopeRoot=\"panel\" shortcut=\"Ctrl+P\" shortcutCommand=\"panel.command\" shortcutScope=\"panel\">\n"
|
||||
" <Column gap=\"10\">\n"
|
||||
" <Button id=\"panel-focus\" text=\"Panel Focus\" />\n"
|
||||
" <Card id=\"widget-shell\" title=\"Widget Scope\" shortcutScopeRoot=\"widget\" shortcut=\"Ctrl+P\" shortcutCommand=\"widget.command\" shortcutScope=\"widget\">\n"
|
||||
" <Column gap=\"10\">\n"
|
||||
" <Button id=\"widget-focus\" text=\"Widget Focus\" />\n"
|
||||
" </Column>\n"
|
||||
" </Card>\n"
|
||||
" </Column>\n"
|
||||
" </Card>\n"
|
||||
" </Column>\n"
|
||||
" </Card>\n"
|
||||
" </Column>\n"
|
||||
"</View>\n");
|
||||
UIDocumentScreenHost host = {};
|
||||
UIScreenPlayer player(host);
|
||||
|
||||
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.shortcut.scope")));
|
||||
|
||||
UIScreenFrameInput initialInput = BuildInputState(1u);
|
||||
initialInput.viewportRect = UIRect(0.0f, 0.0f, 960.0f, 720.0f);
|
||||
const auto& initialFrame = player.Update(initialInput);
|
||||
|
||||
UIRect globalRect = {};
|
||||
UIRect windowRect = {};
|
||||
UIRect panelRect = {};
|
||||
UIRect widgetRect = {};
|
||||
ASSERT_TRUE(TryFindFilledRectForText(initialFrame.drawData, "Global Focus", globalRect));
|
||||
ASSERT_TRUE(TryFindFilledRectForText(initialFrame.drawData, "Window Focus", windowRect));
|
||||
ASSERT_TRUE(TryFindFilledRectForText(initialFrame.drawData, "Panel Focus", panelRect));
|
||||
ASSERT_TRUE(TryFindFilledRectForText(initialFrame.drawData, "Widget Focus", widgetRect));
|
||||
|
||||
std::uint64_t frameIndex = 2u;
|
||||
|
||||
FocusButton(player, initialInput.viewportRect, frameIndex, GetRectCenter(widgetRect));
|
||||
SendCtrlP(player, initialInput.viewportRect, frameIndex);
|
||||
auto inputDebug = host.GetInputDebugSnapshot();
|
||||
EXPECT_EQ(inputDebug.lastShortcutCommandId, "widget.command");
|
||||
EXPECT_EQ(inputDebug.lastShortcutScope, "Widget");
|
||||
EXPECT_TRUE(inputDebug.lastShortcutHandled);
|
||||
EXPECT_NE(inputDebug.focusedStateKey.find("/widget-focus"), std::string::npos);
|
||||
EXPECT_NE(inputDebug.widgetScopeStateKey.find("/widget-shell"), std::string::npos);
|
||||
EXPECT_NE(inputDebug.panelScopeStateKey.find("/panel-shell"), std::string::npos);
|
||||
EXPECT_NE(inputDebug.windowScopeStateKey.find("/window-shell"), std::string::npos);
|
||||
|
||||
FocusButton(player, initialInput.viewportRect, frameIndex, GetRectCenter(panelRect));
|
||||
SendCtrlP(player, initialInput.viewportRect, frameIndex);
|
||||
inputDebug = host.GetInputDebugSnapshot();
|
||||
EXPECT_EQ(inputDebug.lastShortcutCommandId, "panel.command");
|
||||
EXPECT_EQ(inputDebug.lastShortcutScope, "Panel");
|
||||
EXPECT_TRUE(inputDebug.lastShortcutHandled);
|
||||
EXPECT_NE(inputDebug.focusedStateKey.find("/panel-focus"), std::string::npos);
|
||||
|
||||
FocusButton(player, initialInput.viewportRect, frameIndex, GetRectCenter(windowRect));
|
||||
SendCtrlP(player, initialInput.viewportRect, frameIndex);
|
||||
inputDebug = host.GetInputDebugSnapshot();
|
||||
EXPECT_EQ(inputDebug.lastShortcutCommandId, "window.command");
|
||||
EXPECT_EQ(inputDebug.lastShortcutScope, "Window");
|
||||
EXPECT_TRUE(inputDebug.lastShortcutHandled);
|
||||
EXPECT_NE(inputDebug.focusedStateKey.find("/window-focus"), std::string::npos);
|
||||
|
||||
FocusButton(player, initialInput.viewportRect, frameIndex, GetRectCenter(globalRect));
|
||||
SendCtrlP(player, initialInput.viewportRect, frameIndex);
|
||||
inputDebug = host.GetInputDebugSnapshot();
|
||||
EXPECT_EQ(inputDebug.lastShortcutCommandId, "global.command");
|
||||
EXPECT_EQ(inputDebug.lastShortcutScope, "Global");
|
||||
EXPECT_TRUE(inputDebug.lastShortcutHandled);
|
||||
EXPECT_NE(inputDebug.focusedStateKey.find("/global-focus"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(UIRuntimeShortcutScopeTest, DocumentHostSuppresssShortcutAndTabTraversalWhenTextInputScopeIsFocused) {
|
||||
TempFileScope viewFile(
|
||||
"xcui_runtime_shortcut_suppression",
|
||||
".xcui",
|
||||
"<View name=\"Shortcut Suppression Test\" shortcut=\"Ctrl+P\" shortcutCommand=\"global.command\" shortcutScope=\"global\">\n"
|
||||
" <Column padding=\"18\" gap=\"12\">\n"
|
||||
" <Card id=\"panel-shell\" title=\"Panel Scope\" shortcutScopeRoot=\"panel\" shortcut=\"Ctrl+P\" shortcutCommand=\"panel.command\" shortcutScope=\"panel\">\n"
|
||||
" <Column gap=\"10\">\n"
|
||||
" <Button id=\"text-input\" text=\"Text Input Proxy\" textInput=\"true\" />\n"
|
||||
" <Button id=\"after-text\" text=\"After Text\" />\n"
|
||||
" </Column>\n"
|
||||
" </Card>\n"
|
||||
" </Column>\n"
|
||||
"</View>\n");
|
||||
UIDocumentScreenHost host = {};
|
||||
UIScreenPlayer player(host);
|
||||
|
||||
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.shortcut.suppression")));
|
||||
|
||||
UIScreenFrameInput initialInput = BuildInputState(1u);
|
||||
initialInput.viewportRect = UIRect(0.0f, 0.0f, 900.0f, 520.0f);
|
||||
const auto& initialFrame = player.Update(initialInput);
|
||||
|
||||
UIRect textInputRect = {};
|
||||
ASSERT_TRUE(TryFindFilledRectForText(initialFrame.drawData, "Text Input Proxy", textInputRect));
|
||||
|
||||
std::uint64_t frameIndex = 2u;
|
||||
FocusButton(player, initialInput.viewportRect, frameIndex, GetRectCenter(textInputRect));
|
||||
SendCtrlP(player, initialInput.viewportRect, frameIndex);
|
||||
|
||||
auto inputDebug = host.GetInputDebugSnapshot();
|
||||
EXPECT_TRUE(inputDebug.textInputActive);
|
||||
EXPECT_EQ(inputDebug.lastShortcutCommandId, "panel.command");
|
||||
EXPECT_EQ(inputDebug.lastShortcutScope, "Panel");
|
||||
EXPECT_FALSE(inputDebug.lastShortcutHandled);
|
||||
EXPECT_TRUE(inputDebug.lastShortcutSuppressed);
|
||||
EXPECT_EQ(inputDebug.lastResult, "Shortcut suppressed by text input");
|
||||
EXPECT_NE(inputDebug.focusedStateKey.find("/text-input"), std::string::npos);
|
||||
EXPECT_NE(inputDebug.panelScopeStateKey.find("/panel-shell"), std::string::npos);
|
||||
|
||||
SendTab(player, initialInput.viewportRect, frameIndex);
|
||||
inputDebug = host.GetInputDebugSnapshot();
|
||||
EXPECT_TRUE(inputDebug.textInputActive);
|
||||
EXPECT_EQ(inputDebug.lastResult, "Focus traversal suppressed by text input");
|
||||
EXPECT_NE(inputDebug.focusedStateKey.find("/text-input"), std::string::npos);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
|
||||
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::UI::UIRect;
|
||||
using XCEngine::UI::Runtime::UIScreenAsset;
|
||||
using XCEngine::UI::Runtime::UIScreenFrameInput;
|
||||
using XCEngine::UI::Runtime::UIScreenPlayer;
|
||||
using XCEngine::UI::Runtime::UIDocumentScreenHost;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
class TempFileScope {
|
||||
public:
|
||||
TempFileScope(std::string stem, std::string extension, std::string contents) {
|
||||
const auto uniqueId = std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count());
|
||||
m_path = fs::temp_directory_path() / (std::move(stem) + "_" + uniqueId + std::move(extension));
|
||||
std::ofstream output(m_path, std::ios::binary | std::ios::trunc);
|
||||
output << contents;
|
||||
}
|
||||
|
||||
~TempFileScope() {
|
||||
std::error_code ec;
|
||||
fs::remove(m_path, ec);
|
||||
}
|
||||
|
||||
const fs::path& Path() const {
|
||||
return m_path;
|
||||
}
|
||||
|
||||
private:
|
||||
fs::path m_path = {};
|
||||
};
|
||||
|
||||
UIScreenAsset BuildScreenAsset(const fs::path& viewPath, const char* screenId) {
|
||||
UIScreenAsset screen = {};
|
||||
screen.screenId = screenId;
|
||||
screen.documentPath = viewPath.string();
|
||||
return screen;
|
||||
}
|
||||
|
||||
UIScreenFrameInput BuildInputState(std::uint64_t frameIndex = 1u) {
|
||||
UIScreenFrameInput input = {};
|
||||
input.viewportRect = UIRect(0.0f, 0.0f, 960.0f, 720.0f);
|
||||
input.frameIndex = frameIndex;
|
||||
input.focused = true;
|
||||
return input;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(UIRuntimeSplitterValidationTest, InvalidSplitterArityProducesExplicitFrameError) {
|
||||
TempFileScope viewFile(
|
||||
"xcui_runtime_invalid_splitter",
|
||||
".xcui",
|
||||
"<View name=\"Invalid Splitter Test\">\n"
|
||||
" <Column padding=\"16\" gap=\"10\">\n"
|
||||
" <Splitter id=\"broken-splitter\" axis=\"horizontal\" splitRatio=\"0.5\">\n"
|
||||
" <Card title=\"Only Child\" />\n"
|
||||
" </Splitter>\n"
|
||||
" </Column>\n"
|
||||
"</View>\n");
|
||||
|
||||
UIDocumentScreenHost host = {};
|
||||
UIScreenPlayer player(host);
|
||||
|
||||
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.invalid_splitter")));
|
||||
|
||||
const auto& frame = player.Update(BuildInputState());
|
||||
EXPECT_FALSE(frame.errorMessage.empty());
|
||||
EXPECT_NE(frame.errorMessage.find("broken-splitter"), std::string::npos);
|
||||
EXPECT_NE(frame.errorMessage.find("exactly 2 child elements"), std::string::npos);
|
||||
}
|
||||
165
tests/UI/TEST_SPEC.md
Normal file
165
tests/UI/TEST_SPEC.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# XCUI TEST_SPEC
|
||||
|
||||
日期: `2026-04-06`
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本规范只约束 XCUI 模块自身的测试体系,不负责复刻完整 editor 产品。
|
||||
|
||||
XCUI 测试体系固定为两条并行通道:
|
||||
|
||||
1. `unit`
|
||||
2. `integration`
|
||||
|
||||
两者必须同时存在,但职责严格分离:
|
||||
|
||||
- `unit` 负责规则、状态机、路由与回归稳定性,默认可自动执行。
|
||||
- `integration` 负责生成可操作 exe,让人直接检查交互、布局、焦点、shortcut、滚动与视觉状态。
|
||||
|
||||
## 2. 顶层目录
|
||||
|
||||
XCUI 的测试树统一放在:
|
||||
|
||||
```text
|
||||
tests/UI/
|
||||
TEST_SPEC.md
|
||||
Core/
|
||||
unit/
|
||||
integration/
|
||||
Runtime/
|
||||
unit/
|
||||
integration/
|
||||
Editor/
|
||||
unit/
|
||||
integration/
|
||||
```
|
||||
|
||||
三层必须明确分开:
|
||||
|
||||
- `Core`
|
||||
- 共享 UI 基础层测试。
|
||||
- 例如 tree state、layout、focus、input router、shortcut、scroll、text controller。
|
||||
- `Runtime`
|
||||
- 面向游戏运行时 UI 的测试。
|
||||
- 例如 screen stack、layer blocking、runtime 输入路由、runtime-only widget。
|
||||
- `Editor`
|
||||
- 面向编辑器 UI 的测试。
|
||||
- 例如 editor 输入宿主、editor shell 验证场景、editor-only widget。
|
||||
|
||||
禁止把 `Runtime` 和 `Editor` 混在同一个测试目标里。
|
||||
|
||||
## 3. Unit 规范
|
||||
|
||||
`unit` 测试要求:
|
||||
|
||||
- 直接面向底层能力。
|
||||
- 不依赖人工观察。
|
||||
- 默认进入自动化回归。
|
||||
- 新增一块共享能力时,优先先补 `unit`。
|
||||
|
||||
`unit` 测试不负责:
|
||||
|
||||
- 人工手感检查。
|
||||
- 布局观感检查。
|
||||
- 完整交互场景展示。
|
||||
|
||||
## 4. Integration 规范
|
||||
|
||||
`integration` 测试要求:
|
||||
|
||||
- 必须产出可直接运行的 exe。
|
||||
- 一个 exe 只验证一个聚焦场景。
|
||||
- 每次只暴露当前批次需要检查的操作区域,不做大杂烩面板。
|
||||
- 界面中的操作提示默认使用中文,必要时可混用 `hover`、`focus`、`active`、`capture` 等术语。
|
||||
|
||||
`integration` 测试不负责:
|
||||
|
||||
- 模拟完整 editor 产品外壳。
|
||||
- 把多个无关能力塞进同一个窗口。
|
||||
- 代替底层 `unit` 回归。
|
||||
|
||||
## 5. Scenario 目录规范
|
||||
|
||||
### 5.1 Editor
|
||||
|
||||
```text
|
||||
tests/UI/Editor/integration/
|
||||
shared/
|
||||
src/
|
||||
<category>/
|
||||
<scenario>/
|
||||
CMakeLists.txt
|
||||
main.cpp
|
||||
View.xcui
|
||||
captures/
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `shared/` 只放宿主、渲染、截图、scenario registry 等共用基础设施。
|
||||
- `<category>` 表示能力类别,例如 `input`。
|
||||
- `<scenario>` 是最小验证单元。
|
||||
- 一个 `<scenario>` 对应一个 exe。
|
||||
|
||||
### 5.2 Runtime
|
||||
|
||||
```text
|
||||
tests/UI/Runtime/integration/
|
||||
<category>/
|
||||
<scenario>/
|
||||
CMakeLists.txt
|
||||
main.cpp
|
||||
View.xcui
|
||||
captures/
|
||||
```
|
||||
|
||||
Runtime 的集成测试结构与 Editor 保持同一规范,但宿主职责必须与 Editor 分离。
|
||||
|
||||
## 6. 当前已有 Editor 场景
|
||||
|
||||
- `editor.input.keyboard_focus`
|
||||
- `editor.input.pointer_states`
|
||||
- `editor.input.shortcut_scope`
|
||||
- `editor.layout.splitter_resize`
|
||||
|
||||
这些场景只用于验证 XCUI 模块能力,不代表开始复刻完整 editor 面板。
|
||||
|
||||
## 7. 截图规范
|
||||
|
||||
Editor 集成宿主支持:
|
||||
|
||||
- `F12` 手动截图。
|
||||
- 截图只允许截当前 exe 自己的渲染结果。
|
||||
- 截图输出到当前 scenario 自己的 `captures/` 目录。
|
||||
|
||||
输出格式:
|
||||
|
||||
- `captures/latest.png`
|
||||
- `captures/history/<timestamp>_<index>_<reason>.png`
|
||||
|
||||
原则:
|
||||
|
||||
- 不做持续高频自动截图轰炸。
|
||||
- 只在人工检查、问题复现、调试定位时触发截图。
|
||||
|
||||
## 8. 开发顺序
|
||||
|
||||
XCUI 必须坚持自底向上的建设顺序:
|
||||
|
||||
1. 先补共享底层能力。
|
||||
2. 先补对应 `unit`。
|
||||
3. 再补一个聚焦的 `integration` exe。
|
||||
4. 人工检查通过后再继续向上推进。
|
||||
|
||||
禁止事项:
|
||||
|
||||
- 先堆 editor 具体面板,再回头补底层。
|
||||
- 把 `new_editor` 当作 XCUI 主测试入口。
|
||||
- 把一个验证 exe 做成综合试验场。
|
||||
- 为了赶进度写跨层耦合的临时代码。
|
||||
|
||||
## 9. 当前入口约定
|
||||
|
||||
当前 XCUI 的正式验证入口是 `tests/UI`。
|
||||
|
||||
`new_editor` 不是后续 XCUI 测试体系的主入口,也不应继续承载新的测试场景扩展。
|
||||
Reference in New Issue
Block a user