feat(xcui): advance core and editor validation flow

This commit is contained in:
2026-04-06 16:20:46 +08:00
parent 33bb84f650
commit 2d030a97da
128 changed files with 9961 additions and 773 deletions

View File

@@ -2,8 +2,8 @@ cmake_minimum_required(VERSION 3.15)
project(XCEngine_CoreUITests)
add_subdirectory(unit)
add_subdirectory(integration)
add_subdirectory(unit)
add_custom_target(core_ui_unit_tests
DEPENDS

View File

@@ -1 +1,13 @@
add_custom_target(core_ui_integration_tests)
add_subdirectory(shared)
add_subdirectory(input)
add_subdirectory(layout)
add_subdirectory(style)
add_subdirectory(text)
add_custom_target(core_ui_integration_tests
DEPENDS
core_ui_input_integration_tests
core_ui_layout_integration_tests
core_ui_style_integration_tests
core_ui_text_integration_tests
)

View File

@@ -1,8 +1,24 @@
# Core UI Integration Notes
# Core UI Integration Validation
The core XCUI lane currently validates shared primitives through automated unit tests.
This directory contains the manual XCUI validation system for shared Core primitives.
Interactive validation belongs to:
Structure:
- `tests/UI/Runtime/integration/` for game/runtime UI
- `tests/UI/Editor/integration/` for editor UI
- `shared/`: shared host, native renderer, screenshot helper, scenario registry
- `input/`: shared input validation category
- `layout/`: shared layout validation category
- `style/`: shared theme token and style resolution validation category
- `text/`: shared UTF-8 text rendering and textInput focus marker 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 core_ui_integration_tests
```

View File

@@ -0,0 +1,12 @@
add_subdirectory(keyboard_focus)
add_subdirectory(pointer_states)
add_subdirectory(scroll_view)
add_subdirectory(shortcut_scope)
add_custom_target(core_ui_input_integration_tests
DEPENDS
core_ui_input_keyboard_focus_validation
core_ui_input_pointer_states_validation
core_ui_input_scroll_view_validation
core_ui_input_shortcut_scope_validation
)

View File

@@ -0,0 +1,8 @@
# Core Input Integration
这个分类只放共享 XCUI 输入能力的手工验证场景。
规则:
- 一个场景目录对应一个独立 exe
- 共享宿主层只放在 `integration/shared/`
- 不允许把多个无关检查点塞进同一个 exe

View File

@@ -0,0 +1,35 @@
set(CORE_UI_INPUT_KEYBOARD_FOCUS_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
)
add_executable(core_ui_input_keyboard_focus_validation WIN32
main.cpp
${CORE_UI_INPUT_KEYBOARD_FOCUS_RESOURCES}
)
target_include_directories(core_ui_input_keyboard_focus_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_input_keyboard_focus_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(core_ui_input_keyboard_focus_validation PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_input_keyboard_focus_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_input_keyboard_focus_validation PRIVATE
core_ui_integration_host
)
set_target_properties(core_ui_input_keyboard_focus_validation PROPERTIES
OUTPUT_NAME "XCUICoreInputKeyboardFocusValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,15 @@
# Keyboard Focus Validation
可执行 target
- `core_ui_input_keyboard_focus_validation`
运行:
```bash
build\tests\UI\Core\integration\input\keyboard_focus\Debug\XCUICoreInputKeyboardFocusValidation.exe
```
检查点:
1.`Tab`,焦点依次切换三个按钮。
2.`Shift+Tab`,焦点反向切换。
3.`Enter``Space`,当前 `focus` 按钮进入 `active`
4. 松开按键后,`active` 清空。

View File

@@ -1,15 +1,15 @@
<View
name="EditorInputKeyboardFocus"
theme="../../shared/themes/editor_validation.xctheme">
name="CoreInputKeyboardFocus"
theme="../../shared/themes/core_validation.xctheme">
<Column padding="24" gap="16">
<Card
title="Editor Validation | Keyboard Focus"
title="Core Validation | Keyboard Focus"
subtitle="当前批次Tab 焦点遍历 | Enter / Space 激活"
tone="accent"
height="90">
<Column gap="8">
<Text text="这是 editor 侧验证场景,不承载 runtime 游戏 UI。" />
<Text text="这一轮只检查键盘焦点和激活,不混入复杂 editor 面板。" />
<Text text="这是 Core 层验证场景,不承载 runtime 游戏 UI。" />
<Text text="这一轮只检查键盘焦点和激活,不混入上层业务面板。" />
</Column>
</Card>

View File

@@ -1,8 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"editor.input.shortcut_scope");
"core.input.keyboard_focus");
}

View File

@@ -0,0 +1,35 @@
set(CORE_UI_INPUT_POINTER_STATES_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
)
add_executable(core_ui_input_pointer_states_validation WIN32
main.cpp
${CORE_UI_INPUT_POINTER_STATES_RESOURCES}
)
target_include_directories(core_ui_input_pointer_states_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_input_pointer_states_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(core_ui_input_pointer_states_validation PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_input_pointer_states_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_input_pointer_states_validation PRIVATE
core_ui_integration_host
)
set_target_properties(core_ui_input_pointer_states_validation PROPERTIES
OUTPUT_NAME "XCUICoreInputPointerStatesValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,14 @@
# Pointer States Validation
可执行 target
- `core_ui_input_pointer_states_validation`
运行:
```bash
build\tests\UI\Core\integration\input\pointer_states\Debug\XCUICoreInputPointerStatesValidation.exe
```
检查点:
1. hover 左侧按钮,只应变化 `hover`
2. 按住中间按钮,应看到 `focus``active``capture`
3. 拖到右侧再松开,应看到 `capture` 清空route 转到新的目标。

View File

@@ -1,14 +1,14 @@
<View
name="EditorInputPointerStates"
theme="../../shared/themes/editor_validation.xctheme">
name="CoreInputPointerStates"
theme="../../shared/themes/core_validation.xctheme">
<Column padding="24" gap="16">
<Card
title="Editor Validation | Pointer States"
title="Core Validation | Pointer States"
subtitle="当前批次:鼠标 hover / focus / active / capture"
tone="accent"
height="90">
<Column gap="8">
<Text text="这是 editor 侧验证场景,不承载 runtime 游戏 UI。" />
<Text text="这是 Core 层验证场景,不承载 runtime 游戏 UI。" />
<Text text="这一轮只检查鼠标输入状态,不混入别的控件实验。" />
</Column>
</Card>

View File

@@ -1,8 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"editor.input.keyboard_focus");
"core.input.pointer_states");
}

View File

@@ -0,0 +1,35 @@
set(CORE_UI_INPUT_SCROLL_VIEW_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
)
add_executable(core_ui_input_scroll_view_validation WIN32
main.cpp
${CORE_UI_INPUT_SCROLL_VIEW_RESOURCES}
)
target_include_directories(core_ui_input_scroll_view_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_input_scroll_view_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(core_ui_input_scroll_view_validation PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_input_scroll_view_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_input_scroll_view_validation PRIVATE
core_ui_integration_host
)
set_target_properties(core_ui_input_scroll_view_validation PROPERTIES
OUTPUT_NAME "XCUICoreInputScrollViewValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -1,6 +1,6 @@
<View
name="EditorInputScrollView"
theme="../../shared/themes/editor_validation.xctheme">
name="CoreInputScrollView"
theme="../../shared/themes/core_validation.xctheme">
<Column padding="20" gap="12">
<Card
title="功能ScrollView 滚动 / clip / overflow"
@@ -11,7 +11,7 @@
<Text text="1. 把鼠标放到下方日志区内滚动滚轮:内容应上下移动,右下角 Scroll target 应落到 validation-scroll。" />
<Text text="2. 连续向下滚到末尾再继续滚Offset 应被 clampResult 应显示 Scroll delta clamped to current offset。" />
<Text text="3. 把鼠标移到日志区外再滚动日志位置不应变化Result 应显示 No hovered ScrollView。" />
<Text text="4. 这个场景只验证 ScrollView 基础能力,不验证 editor 业务面板。" />
<Text text="4. 这个场景只验证 ScrollView 基础能力,不验证上层业务面板。" />
</Column>
</Card>

View File

@@ -1,8 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"editor.input.pointer_states");
"core.input.scroll_view");
}

View File

@@ -0,0 +1,35 @@
set(CORE_UI_INPUT_SHORTCUT_SCOPE_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
)
add_executable(core_ui_input_shortcut_scope_validation WIN32
main.cpp
${CORE_UI_INPUT_SHORTCUT_SCOPE_RESOURCES}
)
target_include_directories(core_ui_input_shortcut_scope_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_input_shortcut_scope_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(core_ui_input_shortcut_scope_validation PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_input_shortcut_scope_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_input_shortcut_scope_validation PRIVATE
core_ui_integration_host
)
set_target_properties(core_ui_input_shortcut_scope_validation PROPERTIES
OUTPUT_NAME "XCUICoreInputShortcutScopeValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -1,13 +1,13 @@
<View
name="EditorInputShortcutScope"
theme="../../shared/themes/editor_validation.xctheme"
name="CoreInputShortcutScope"
theme="../../shared/themes/core_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"
title="Core Validation | Shortcut Scope"
subtitle="验证功能:Core shortcut scope 路由与 text input suppression"
tone="accent"
height="100">
<Column gap="6">

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"core.input.shortcut_scope");
}

View File

@@ -0,0 +1,10 @@
add_subdirectory(splitter_resize)
add_subdirectory(tab_strip_selection)
add_subdirectory(workspace_compose)
add_custom_target(core_ui_layout_integration_tests
DEPENDS
core_ui_layout_splitter_resize_validation
core_ui_layout_tab_strip_selection_validation
core_ui_layout_workspace_compose_validation
)

View File

@@ -0,0 +1,35 @@
set(CORE_UI_LAYOUT_SPLITTER_RESIZE_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
)
add_executable(core_ui_layout_splitter_resize_validation WIN32
main.cpp
${CORE_UI_LAYOUT_SPLITTER_RESIZE_RESOURCES}
)
target_include_directories(core_ui_layout_splitter_resize_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_layout_splitter_resize_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(core_ui_layout_splitter_resize_validation PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_layout_splitter_resize_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_layout_splitter_resize_validation PRIVATE
core_ui_integration_host
)
set_target_properties(core_ui_layout_splitter_resize_validation PROPERTIES
OUTPUT_NAME "XCUICoreLayoutSplitterResizeValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -1,6 +1,6 @@
<View
name="EditorSplitterResizeValidation"
theme="../../shared/themes/editor_validation.xctheme">
name="CoreSplitterResizeValidation"
theme="../../shared/themes/core_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12">
<Card
title="功能Splitter / pane resize"

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"core.layout.splitter_resize");
}

View File

@@ -0,0 +1,35 @@
set(CORE_UI_LAYOUT_TAB_STRIP_SELECTION_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
)
add_executable(core_ui_layout_tab_strip_selection_validation WIN32
main.cpp
${CORE_UI_LAYOUT_TAB_STRIP_SELECTION_RESOURCES}
)
target_include_directories(core_ui_layout_tab_strip_selection_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_layout_tab_strip_selection_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(core_ui_layout_tab_strip_selection_validation PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_layout_tab_strip_selection_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_layout_tab_strip_selection_validation PRIVATE
core_ui_integration_host
)
set_target_properties(core_ui_layout_tab_strip_selection_validation PROPERTIES
OUTPUT_NAME "XCUICoreLayoutTabStripSelectionValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -1,6 +1,6 @@
<View
name="EditorTabStripSelectionValidation"
theme="../../shared/themes/editor_validation.xctheme">
name="CoreTabStripSelectionValidation"
theme="../../shared/themes/core_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12">
<Card
title="功能TabStrip 选择切换"
@@ -11,12 +11,12 @@
<Text text="1. 点击 Scene / Console / Inspector 任一 tab下方内容区应立即切换旧内容不应继续显示。" />
<Text text="2. 先点击一个 tab 让它获得 focus再按 Left / Right / Home / Endselected tab 应变化。" />
<Text text="3. 右下角 Result 正常应显示 Tab selected 或 Tab navigatedFocused 应落在当前 tab。" />
<Text text="4. 这个场景只检查 TabStrip 基础能力,不检查 editor 业务面板。" />
<Text text="4. 这个场景只检查 TabStrip 基础能力,不检查上层业务面板。" />
</Column>
</Card>
<TabStrip
id="editor-workspace-tabs"
id="core-workspace-tabs"
tabHeaderHeight="34"
tabMinWidth="96"
height="fill">

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"core.layout.tab_strip_selection");
}

View File

@@ -0,0 +1,35 @@
set(CORE_UI_LAYOUT_WORKSPACE_COMPOSE_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
)
add_executable(core_ui_layout_workspace_compose_validation WIN32
main.cpp
${CORE_UI_LAYOUT_WORKSPACE_COMPOSE_RESOURCES}
)
target_include_directories(core_ui_layout_workspace_compose_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_layout_workspace_compose_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(core_ui_layout_workspace_compose_validation PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_layout_workspace_compose_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_layout_workspace_compose_validation PRIVATE
core_ui_integration_host
)
set_target_properties(core_ui_layout_workspace_compose_validation PROPERTIES
OUTPUT_NAME "XCUICoreLayoutWorkspaceComposeValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -1,10 +1,10 @@
<View
name="EditorWorkspaceComposeValidation"
theme="../../shared/themes/editor_validation.xctheme">
name="CoreWorkspaceComposeValidation"
theme="../../shared/themes/core_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12">
<Card
title="功能Workspace compose"
subtitle="只检查 editor 工作区的 split + tab + placeholder 组合,不检查任何业务面板"
subtitle="只检查共享工作区组合原语的 split + tab + placeholder不检查任何业务面板"
tone="accent"
height="156">
<Column gap="6">

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"core.layout.workspace_compose");
}

View File

@@ -0,0 +1,61 @@
file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH)
add_library(core_ui_validation_registry STATIC
src/CoreValidationScenario.cpp
)
target_include_directories(core_ui_validation_registry
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_validation_registry
PUBLIC
XCENGINE_CORE_UI_TESTS_REPO_ROOT="${XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(core_ui_validation_registry PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_validation_registry PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_validation_registry
PUBLIC
XCEngine
)
add_library(core_ui_integration_host STATIC
src/AutoScreenshot.cpp
src/Application.cpp
src/NativeRenderer.cpp
)
target_include_directories(core_ui_integration_host
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_integration_host
PUBLIC
UNICODE
_UNICODE
XCENGINE_CORE_UI_TESTS_REPO_ROOT="${XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(core_ui_integration_host PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_integration_host PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_integration_host
PUBLIC
core_ui_validation_registry
XCEngine
d2d1.lib
dwrite.lib
windowscodecs.lib
)

View File

@@ -0,0 +1,802 @@
#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_CORE_UI_TESTS_REPO_ROOT
#define XCENGINE_CORE_UI_TESTS_REPO_ROOT "."
#endif
namespace XCEngine::Tests::CoreUI {
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"XCUICoreValidationHost";
constexpr const wchar_t* kWindowTitle = L"XCUI Core 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_CORE_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 CoreValidationScenario* initialScenario = m_requestedScenarioId.empty()
? &GetDefaultCoreValidationScenario()
: FindCoreValidationScenario(m_requestedScenarioId);
if (initialScenario == nullptr) {
initialScenario = &GetDefaultCoreValidationScenario();
}
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
: "Core UI Validation";
m_runtimeError = frame.errorMessage;
}
if (drawData.Empty()) {
m_runtimeStatus = "Core 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 CoreValidationScenario* scenario = m_requestedScenarioId.empty()
? &GetDefaultCoreValidationScenario()
: FindCoreValidationScenario(m_requestedScenarioId);
if (scenario == nullptr) {
scenario = &GetDefaultCoreValidationScenario();
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 : "Core 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();
const auto& scrollDebug = m_documentHost.GetScrollDebugSnapshot();
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));
}
detailLines.push_back(
"Scroll target | Primary: " +
ExtractStateKeyTail(scrollDebug.lastTargetStateKey) +
" | " +
ExtractStateKeyTail(scrollDebug.primaryTargetStateKey));
detailLines.push_back(
"Scroll offset B/A: " +
FormatFloat(scrollDebug.lastOffsetBefore) +
" -> " +
FormatFloat(scrollDebug.lastOffsetAfter) +
" | overflow " +
FormatFloat(scrollDebug.lastOverflow));
detailLines.push_back(
"Scroll H/T: " +
std::to_string(scrollDebug.handledWheelEventCount) +
"/" +
std::to_string(scrollDebug.totalWheelEventCount) +
" | " +
(scrollDebug.lastResult.empty() ? std::string("n/a") : scrollDebug.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("Core 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() ? "Core 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 RunCoreUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId) {
Application application(std::move(requestedScenarioId));
return application.Run(hInstance, nCmdShow);
}
} // namespace XCEngine::Tests::CoreUI

View File

@@ -0,0 +1,83 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include "AutoScreenshot.h"
#include "CoreValidationScenario.h"
#include "InputModifierTracker.h"
#include "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::CoreUI {
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;
Host::NativeRenderer m_renderer;
Host::AutoScreenshotController m_autoScreenshot;
::XCEngine::UI::Runtime::UIDocumentScreenHost m_documentHost;
::XCEngine::UI::Runtime::UIScreenPlayer m_screenPlayer;
::XCEngine::UI::Runtime::UIScreenAsset m_screenAsset = {};
const CoreValidationScenario* 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 = {};
Host::InputModifierTracker m_inputModifierTracker = {};
bool m_trackingMouseLeave = false;
bool m_useStructuredScreen = false;
std::string m_runtimeStatus = {};
std::string m_runtimeError = {};
};
int RunCoreUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId = {});
} // namespace XCEngine::Tests::CoreUI

View File

@@ -0,0 +1,165 @@
#include "AutoScreenshot.h"
#include "NativeRenderer.h"
#include <chrono>
#include <cctype>
#include <cstdio>
#include <sstream>
#include <system_error>
namespace XCEngine::Tests::CoreUI::Host {
void AutoScreenshotController::Initialize(const std::filesystem::path& captureRoot) {
m_captureRoot = captureRoot.lexically_normal();
m_historyRoot = (m_captureRoot / "history").lexically_normal();
m_latestCapturePath = (m_captureRoot / "latest.png").lexically_normal();
m_captureCount = 0;
m_capturePending = false;
m_pendingReason.clear();
m_lastCaptureSummary.clear();
m_lastCaptureError.clear();
}
void AutoScreenshotController::Shutdown() {
m_capturePending = false;
m_pendingReason.clear();
}
void AutoScreenshotController::RequestCapture(std::string reason) {
m_pendingReason = reason.empty() ? "capture" : std::move(reason);
m_capturePending = true;
}
void AutoScreenshotController::CaptureIfRequested(
NativeRenderer& renderer,
const ::XCEngine::UI::UIDrawData& drawData,
unsigned int width,
unsigned int height,
bool framePresented) {
if (!m_capturePending || !framePresented || drawData.Empty() || width == 0u || height == 0u) {
return;
}
std::error_code errorCode = {};
std::filesystem::create_directories(m_captureRoot, errorCode);
if (errorCode) {
m_lastCaptureError = "Failed to create screenshot directory: " + m_captureRoot.string();
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
std::filesystem::create_directories(m_historyRoot, errorCode);
if (errorCode) {
m_lastCaptureError = "Failed to create screenshot directory: " + m_historyRoot.string();
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
std::string captureError = {};
const std::filesystem::path historyPath = BuildHistoryCapturePath(m_pendingReason);
if (!renderer.CaptureToPng(drawData, width, height, historyPath, captureError)) {
m_lastCaptureError = std::move(captureError);
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
errorCode.clear();
std::filesystem::copy_file(
historyPath,
m_latestCapturePath,
std::filesystem::copy_options::overwrite_existing,
errorCode);
if (errorCode) {
m_lastCaptureError = "Failed to update latest screenshot: " + m_latestCapturePath.string();
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
++m_captureCount;
m_lastCaptureError.clear();
m_lastCaptureSummary = "Shot: latest.png | " + historyPath.filename().string();
m_capturePending = false;
m_pendingReason.clear();
}
bool AutoScreenshotController::HasPendingCapture() const {
return m_capturePending;
}
const std::filesystem::path& AutoScreenshotController::GetLatestCapturePath() const {
return m_latestCapturePath;
}
const std::string& AutoScreenshotController::GetLastCaptureSummary() const {
return m_lastCaptureSummary;
}
const std::string& AutoScreenshotController::GetLastCaptureError() const {
return m_lastCaptureError;
}
std::filesystem::path AutoScreenshotController::BuildHistoryCapturePath(std::string_view reason) const {
std::ostringstream filename;
filename << BuildTimestampString()
<< '_'
<< (m_captureCount + 1u)
<< '_'
<< SanitizeReason(reason)
<< ".png";
return (m_historyRoot / filename.str()).lexically_normal();
}
std::string AutoScreenshotController::BuildTimestampString() {
const auto now = std::chrono::system_clock::now();
const std::time_t currentTime = std::chrono::system_clock::to_time_t(now);
std::tm localTime = {};
localtime_s(&localTime, &currentTime);
char buffer[32] = {};
std::snprintf(
buffer,
sizeof(buffer),
"%04d%02d%02d_%02d%02d%02d",
localTime.tm_year + 1900,
localTime.tm_mon + 1,
localTime.tm_mday,
localTime.tm_hour,
localTime.tm_min,
localTime.tm_sec);
return buffer;
}
std::string AutoScreenshotController::SanitizeReason(std::string_view reason) {
std::string sanitized = {};
sanitized.reserve(reason.size());
bool lastWasSeparator = false;
for (const unsigned char value : reason) {
if (std::isalnum(value)) {
sanitized.push_back(static_cast<char>(std::tolower(value)));
lastWasSeparator = false;
continue;
}
if (!lastWasSeparator) {
sanitized.push_back('_');
lastWasSeparator = true;
}
}
while (!sanitized.empty() && sanitized.front() == '_') {
sanitized.erase(sanitized.begin());
}
while (!sanitized.empty() && sanitized.back() == '_') {
sanitized.pop_back();
}
return sanitized.empty() ? "capture" : sanitized;
}
} // namespace XCEngine::Tests::CoreUI::Host

View File

@@ -0,0 +1,52 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCEngine/UI/DrawData.h>
#include <cstdint>
#include <filesystem>
#include <string>
#include <string_view>
namespace XCEngine::Tests::CoreUI::Host {
class NativeRenderer;
class AutoScreenshotController {
public:
void Initialize(const std::filesystem::path& captureRoot);
void Shutdown();
void RequestCapture(std::string reason);
void CaptureIfRequested(
NativeRenderer& renderer,
const ::XCEngine::UI::UIDrawData& drawData,
unsigned int width,
unsigned int height,
bool framePresented);
bool HasPendingCapture() const;
const std::filesystem::path& GetLatestCapturePath() const;
const std::string& GetLastCaptureSummary() const;
const std::string& GetLastCaptureError() const;
private:
std::filesystem::path BuildHistoryCapturePath(std::string_view reason) const;
static std::string BuildTimestampString();
static std::string SanitizeReason(std::string_view reason);
std::filesystem::path m_captureRoot = {};
std::filesystem::path m_historyRoot = {};
std::filesystem::path m_latestCapturePath = {};
std::string m_pendingReason = {};
std::string m_lastCaptureSummary = {};
std::string m_lastCaptureError = {};
std::uint64_t m_captureCount = 0;
bool m_capturePending = false;
};
} // namespace XCEngine::Tests::CoreUI::Host

View File

@@ -0,0 +1,130 @@
#include "CoreValidationScenario.h"
#include <array>
#ifndef XCENGINE_CORE_UI_TESTS_REPO_ROOT
#define XCENGINE_CORE_UI_TESTS_REPO_ROOT "."
#endif
namespace XCEngine::Tests::CoreUI {
namespace {
namespace fs = std::filesystem;
fs::path RepoRootPath() {
std::string root = XCENGINE_CORE_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<CoreValidationScenario, 9>& GetCoreValidationScenarios() {
static const std::array<CoreValidationScenario, 9> scenarios = { {
{
"core.input.keyboard_focus",
UIValidationDomain::Core,
"input",
"Core Input | Keyboard Focus",
RepoRelative("tests/UI/Core/integration/input/keyboard_focus/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/input/keyboard_focus/captures")
},
{
"core.input.pointer_states",
UIValidationDomain::Core,
"input",
"Core Input | Pointer States",
RepoRelative("tests/UI/Core/integration/input/pointer_states/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/input/pointer_states/captures")
},
{
"core.input.scroll_view",
UIValidationDomain::Core,
"input",
"Core Input | Scroll View",
RepoRelative("tests/UI/Core/integration/input/scroll_view/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/input/scroll_view/captures")
},
{
"core.input.shortcut_scope",
UIValidationDomain::Core,
"input",
"Core Input | Shortcut Scope",
RepoRelative("tests/UI/Core/integration/input/shortcut_scope/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/input/shortcut_scope/captures")
},
{
"core.layout.splitter_resize",
UIValidationDomain::Core,
"layout",
"Core Layout | Splitter Resize",
RepoRelative("tests/UI/Core/integration/layout/splitter_resize/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/layout/splitter_resize/captures")
},
{
"core.layout.tab_strip_selection",
UIValidationDomain::Core,
"layout",
"Core Layout | TabStrip Selection",
RepoRelative("tests/UI/Core/integration/layout/tab_strip_selection/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/layout/tab_strip_selection/captures")
},
{
"core.layout.workspace_compose",
UIValidationDomain::Core,
"layout",
"Core Layout | Workspace Compose",
RepoRelative("tests/UI/Core/integration/layout/workspace_compose/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/layout/workspace_compose/captures")
},
{
"core.style.theme_tokens",
UIValidationDomain::Core,
"style",
"Core Style | Theme Tokens",
RepoRelative("tests/UI/Core/integration/style/theme_tokens/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/style/theme_tokens/captures")
},
{
"core.text.utf8_focus_surface",
UIValidationDomain::Core,
"text",
"Core Text | UTF-8 Focus Surface",
RepoRelative("tests/UI/Core/integration/text/utf8_focus_surface/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/text/utf8_focus_surface/captures")
}
} };
return scenarios;
}
} // namespace
const CoreValidationScenario& GetDefaultCoreValidationScenario() {
return GetCoreValidationScenarios().front();
}
const CoreValidationScenario* FindCoreValidationScenario(std::string_view id) {
for (const CoreValidationScenario& scenario : GetCoreValidationScenarios()) {
if (scenario.id == id) {
return &scenario;
}
}
return nullptr;
}
} // namespace XCEngine::Tests::CoreUI

View File

@@ -0,0 +1,26 @@
#pragma once
#include <filesystem>
#include <string>
#include <string_view>
namespace XCEngine::Tests::CoreUI {
enum class UIValidationDomain : unsigned char {
Core = 0
};
struct CoreValidationScenario {
std::string id = {};
UIValidationDomain domain = UIValidationDomain::Core;
std::string categoryId = {};
std::string displayName = {};
std::filesystem::path documentPath = {};
std::filesystem::path themePath = {};
std::filesystem::path captureRootPath = {};
};
const CoreValidationScenario& GetDefaultCoreValidationScenario();
const CoreValidationScenario* FindCoreValidationScenario(std::string_view id);
} // namespace XCEngine::Tests::CoreUI

View File

@@ -0,0 +1,173 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCEngine/UI/Types.h>
#include <windows.h>
#include <cstddef>
#include <cstdint>
namespace XCEngine::Tests::CoreUI::Host {
class InputModifierTracker {
public:
void Reset() {
m_leftShift = false;
m_rightShift = false;
m_leftControl = false;
m_rightControl = false;
m_leftAlt = false;
m_rightAlt = false;
m_leftSuper = false;
m_rightSuper = false;
}
void SyncFromSystemState() {
m_leftShift = (GetKeyState(VK_LSHIFT) & 0x8000) != 0;
m_rightShift = (GetKeyState(VK_RSHIFT) & 0x8000) != 0;
m_leftControl = (GetKeyState(VK_LCONTROL) & 0x8000) != 0;
m_rightControl = (GetKeyState(VK_RCONTROL) & 0x8000) != 0;
m_leftAlt = (GetKeyState(VK_LMENU) & 0x8000) != 0;
m_rightAlt = (GetKeyState(VK_RMENU) & 0x8000) != 0;
m_leftSuper = (GetKeyState(VK_LWIN) & 0x8000) != 0;
m_rightSuper = (GetKeyState(VK_RWIN) & 0x8000) != 0;
}
::XCEngine::UI::UIInputModifiers GetCurrentModifiers() const {
return BuildModifiers();
}
::XCEngine::UI::UIInputModifiers BuildPointerModifiers(std::size_t wParam) const {
::XCEngine::UI::UIInputModifiers modifiers = BuildModifiers();
modifiers.shift = modifiers.shift || (wParam & MK_SHIFT) != 0;
modifiers.control = modifiers.control || (wParam & MK_CONTROL) != 0;
return modifiers;
}
::XCEngine::UI::UIInputModifiers ApplyKeyMessage(
::XCEngine::UI::UIInputEventType type,
WPARAM wParam,
LPARAM lParam) {
if (type == ::XCEngine::UI::UIInputEventType::KeyDown) {
SetModifierState(ResolveModifierKey(wParam, lParam), true);
} else if (type == ::XCEngine::UI::UIInputEventType::KeyUp) {
SetModifierState(ResolveModifierKey(wParam, lParam), false);
}
return BuildModifiers();
}
private:
enum class ModifierKey : std::uint8_t {
None = 0,
LeftShift,
RightShift,
LeftControl,
RightControl,
LeftAlt,
RightAlt,
LeftSuper,
RightSuper
};
static bool IsExtendedKey(LPARAM lParam) {
return (static_cast<std::uint32_t>(lParam) & 0x01000000u) != 0u;
}
static std::uint32_t ExtractScanCode(LPARAM lParam) {
return (static_cast<std::uint32_t>(lParam) >> 16u) & 0xffu;
}
static ModifierKey ResolveModifierKey(WPARAM wParam, LPARAM lParam) {
switch (static_cast<std::uint32_t>(wParam)) {
case VK_SHIFT: {
const UINT shiftVirtualKey = MapVirtualKeyW(ExtractScanCode(lParam), MAPVK_VSC_TO_VK_EX);
return shiftVirtualKey == VK_RSHIFT
? ModifierKey::RightShift
: ModifierKey::LeftShift;
}
case VK_LSHIFT:
return ModifierKey::LeftShift;
case VK_RSHIFT:
return ModifierKey::RightShift;
case VK_CONTROL:
return IsExtendedKey(lParam)
? ModifierKey::RightControl
: ModifierKey::LeftControl;
case VK_LCONTROL:
return ModifierKey::LeftControl;
case VK_RCONTROL:
return ModifierKey::RightControl;
case VK_MENU:
return IsExtendedKey(lParam)
? ModifierKey::RightAlt
: ModifierKey::LeftAlt;
case VK_LMENU:
return ModifierKey::LeftAlt;
case VK_RMENU:
return ModifierKey::RightAlt;
case VK_LWIN:
return ModifierKey::LeftSuper;
case VK_RWIN:
return ModifierKey::RightSuper;
default:
return ModifierKey::None;
}
}
void SetModifierState(ModifierKey key, bool pressed) {
switch (key) {
case ModifierKey::LeftShift:
m_leftShift = pressed;
break;
case ModifierKey::RightShift:
m_rightShift = pressed;
break;
case ModifierKey::LeftControl:
m_leftControl = pressed;
break;
case ModifierKey::RightControl:
m_rightControl = pressed;
break;
case ModifierKey::LeftAlt:
m_leftAlt = pressed;
break;
case ModifierKey::RightAlt:
m_rightAlt = pressed;
break;
case ModifierKey::LeftSuper:
m_leftSuper = pressed;
break;
case ModifierKey::RightSuper:
m_rightSuper = pressed;
break;
case ModifierKey::None:
default:
break;
}
}
::XCEngine::UI::UIInputModifiers BuildModifiers() const {
::XCEngine::UI::UIInputModifiers modifiers = {};
modifiers.shift = m_leftShift || m_rightShift;
modifiers.control = m_leftControl || m_rightControl;
modifiers.alt = m_leftAlt || m_rightAlt;
modifiers.super = m_leftSuper || m_rightSuper;
return modifiers;
}
bool m_leftShift = false;
bool m_rightShift = false;
bool m_leftControl = false;
bool m_rightControl = false;
bool m_leftAlt = false;
bool m_rightAlt = false;
bool m_leftSuper = false;
bool m_rightSuper = false;
};
} // namespace XCEngine::Tests::CoreUI::Host

View File

@@ -0,0 +1,485 @@
#include "NativeRenderer.h"
#include <algorithm>
#include <cmath>
#include <filesystem>
namespace XCEngine::Tests::CoreUI::Host {
namespace {
D2D1_RECT_F ToD2DRect(const ::XCEngine::UI::UIRect& rect) {
return D2D1::RectF(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height);
}
std::string HrToString(const char* operation, HRESULT hr) {
char buffer[128] = {};
sprintf_s(buffer, "%s failed with hr=0x%08X.", operation, static_cast<unsigned int>(hr));
return buffer;
}
} // namespace
bool NativeRenderer::Initialize(HWND hwnd) {
Shutdown();
if (hwnd == nullptr) {
return false;
}
m_hwnd = hwnd;
if (FAILED(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, m_d2dFactory.ReleaseAndGetAddressOf()))) {
Shutdown();
return false;
}
if (FAILED(DWriteCreateFactory(
DWRITE_FACTORY_TYPE_SHARED,
__uuidof(IDWriteFactory),
reinterpret_cast<IUnknown**>(m_dwriteFactory.ReleaseAndGetAddressOf())))) {
Shutdown();
return false;
}
return EnsureRenderTarget();
}
void NativeRenderer::Shutdown() {
m_textFormats.clear();
m_solidBrush.Reset();
m_renderTarget.Reset();
m_wicFactory.Reset();
m_dwriteFactory.Reset();
m_d2dFactory.Reset();
if (m_wicComInitialized) {
CoUninitialize();
m_wicComInitialized = false;
}
m_hwnd = nullptr;
}
void NativeRenderer::Resize(UINT width, UINT height) {
if (!m_renderTarget || width == 0 || height == 0) {
return;
}
const HRESULT hr = m_renderTarget->Resize(D2D1::SizeU(width, height));
if (hr == D2DERR_RECREATE_TARGET) {
DiscardRenderTarget();
}
}
bool NativeRenderer::Render(const ::XCEngine::UI::UIDrawData& drawData) {
if (!EnsureRenderTarget()) {
return false;
}
const bool rendered = RenderToTarget(*m_renderTarget.Get(), *m_solidBrush.Get(), drawData);
const HRESULT hr = m_renderTarget->EndDraw();
if (hr == D2DERR_RECREATE_TARGET) {
DiscardRenderTarget();
return false;
}
return rendered && SUCCEEDED(hr);
}
bool NativeRenderer::CaptureToPng(
const ::XCEngine::UI::UIDrawData& drawData,
UINT width,
UINT height,
const std::filesystem::path& outputPath,
std::string& outError) {
outError.clear();
if (width == 0 || height == 0) {
outError = "CaptureToPng rejected an empty render size.";
return false;
}
if (!m_d2dFactory || !m_dwriteFactory) {
outError = "CaptureToPng requires an initialized NativeRenderer.";
return false;
}
if (!EnsureWicFactory(outError)) {
return false;
}
std::error_code errorCode = {};
std::filesystem::create_directories(outputPath.parent_path(), errorCode);
if (errorCode) {
outError = "Failed to create screenshot directory: " + outputPath.parent_path().string();
return false;
}
Microsoft::WRL::ComPtr<IWICBitmap> bitmap;
HRESULT hr = m_wicFactory->CreateBitmap(
width,
height,
GUID_WICPixelFormat32bppPBGRA,
WICBitmapCacheOnLoad,
bitmap.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("IWICImagingFactory::CreateBitmap", hr);
return false;
}
const D2D1_RENDER_TARGET_PROPERTIES renderTargetProperties = D2D1::RenderTargetProperties(
D2D1_RENDER_TARGET_TYPE_DEFAULT,
D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED));
Microsoft::WRL::ComPtr<ID2D1RenderTarget> offscreenRenderTarget;
hr = m_d2dFactory->CreateWicBitmapRenderTarget(
bitmap.Get(),
renderTargetProperties,
offscreenRenderTarget.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("ID2D1Factory::CreateWicBitmapRenderTarget", hr);
return false;
}
Microsoft::WRL::ComPtr<ID2D1SolidColorBrush> offscreenBrush;
hr = offscreenRenderTarget->CreateSolidColorBrush(
D2D1::ColorF(1.0f, 1.0f, 1.0f, 1.0f),
offscreenBrush.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("ID2D1RenderTarget::CreateSolidColorBrush", hr);
return false;
}
const bool rendered = RenderToTarget(*offscreenRenderTarget.Get(), *offscreenBrush.Get(), drawData);
hr = offscreenRenderTarget->EndDraw();
if (!rendered || FAILED(hr)) {
outError = HrToString("ID2D1RenderTarget::EndDraw", hr);
return false;
}
const std::wstring wideOutputPath = outputPath.wstring();
Microsoft::WRL::ComPtr<IWICStream> stream;
hr = m_wicFactory->CreateStream(stream.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("IWICImagingFactory::CreateStream", hr);
return false;
}
hr = stream->InitializeFromFilename(wideOutputPath.c_str(), GENERIC_WRITE);
if (FAILED(hr)) {
outError = HrToString("IWICStream::InitializeFromFilename", hr);
return false;
}
Microsoft::WRL::ComPtr<IWICBitmapEncoder> encoder;
hr = m_wicFactory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("IWICImagingFactory::CreateEncoder", hr);
return false;
}
hr = encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache);
if (FAILED(hr)) {
outError = HrToString("IWICBitmapEncoder::Initialize", hr);
return false;
}
Microsoft::WRL::ComPtr<IWICBitmapFrameEncode> frame;
Microsoft::WRL::ComPtr<IPropertyBag2> propertyBag;
hr = encoder->CreateNewFrame(frame.ReleaseAndGetAddressOf(), propertyBag.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("IWICBitmapEncoder::CreateNewFrame", hr);
return false;
}
hr = frame->Initialize(propertyBag.Get());
if (FAILED(hr)) {
outError = HrToString("IWICBitmapFrameEncode::Initialize", hr);
return false;
}
hr = frame->SetSize(width, height);
if (FAILED(hr)) {
outError = HrToString("IWICBitmapFrameEncode::SetSize", hr);
return false;
}
WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat32bppPBGRA;
hr = frame->SetPixelFormat(&pixelFormat);
if (FAILED(hr)) {
outError = HrToString("IWICBitmapFrameEncode::SetPixelFormat", hr);
return false;
}
hr = frame->WriteSource(bitmap.Get(), nullptr);
if (FAILED(hr)) {
outError = HrToString("IWICBitmapFrameEncode::WriteSource", hr);
return false;
}
hr = frame->Commit();
if (FAILED(hr)) {
outError = HrToString("IWICBitmapFrameEncode::Commit", hr);
return false;
}
hr = encoder->Commit();
if (FAILED(hr)) {
outError = HrToString("IWICBitmapEncoder::Commit", hr);
return false;
}
return true;
}
bool NativeRenderer::EnsureRenderTarget() {
if (!m_hwnd || !m_d2dFactory || !m_dwriteFactory) {
return false;
}
return CreateDeviceResources();
}
bool NativeRenderer::EnsureWicFactory(std::string& outError) {
outError.clear();
if (m_wicFactory) {
return true;
}
const HRESULT initHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (FAILED(initHr) && initHr != RPC_E_CHANGED_MODE) {
outError = HrToString("CoInitializeEx", initHr);
return false;
}
if (SUCCEEDED(initHr)) {
m_wicComInitialized = true;
}
const HRESULT factoryHr = CoCreateInstance(
CLSID_WICImagingFactory,
nullptr,
CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(m_wicFactory.ReleaseAndGetAddressOf()));
if (FAILED(factoryHr)) {
outError = HrToString("CoCreateInstance(CLSID_WICImagingFactory)", factoryHr);
return false;
}
return true;
}
void NativeRenderer::DiscardRenderTarget() {
m_solidBrush.Reset();
m_renderTarget.Reset();
}
bool NativeRenderer::CreateDeviceResources() {
if (m_renderTarget) {
return true;
}
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const UINT width = static_cast<UINT>((std::max)(clientRect.right - clientRect.left, 1L));
const UINT height = static_cast<UINT>((std::max)(clientRect.bottom - clientRect.top, 1L));
const D2D1_RENDER_TARGET_PROPERTIES renderTargetProps = D2D1::RenderTargetProperties();
const D2D1_HWND_RENDER_TARGET_PROPERTIES hwndProps = D2D1::HwndRenderTargetProperties(
m_hwnd,
D2D1::SizeU(width, height));
if (FAILED(m_d2dFactory->CreateHwndRenderTarget(
renderTargetProps,
hwndProps,
m_renderTarget.ReleaseAndGetAddressOf()))) {
return false;
}
if (FAILED(m_renderTarget->CreateSolidColorBrush(
D2D1::ColorF(1.0f, 1.0f, 1.0f, 1.0f),
m_solidBrush.ReleaseAndGetAddressOf()))) {
DiscardRenderTarget();
return false;
}
m_renderTarget->SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_GRAYSCALE);
return true;
}
bool NativeRenderer::RenderToTarget(
ID2D1RenderTarget& renderTarget,
ID2D1SolidColorBrush& solidBrush,
const ::XCEngine::UI::UIDrawData& drawData) {
renderTarget.SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_GRAYSCALE);
renderTarget.BeginDraw();
renderTarget.Clear(D2D1::ColorF(0.04f, 0.05f, 0.06f, 1.0f));
std::vector<D2D1_RECT_F> clipStack = {};
for (const ::XCEngine::UI::UIDrawList& drawList : drawData.GetDrawLists()) {
for (const ::XCEngine::UI::UIDrawCommand& command : drawList.GetCommands()) {
RenderCommand(renderTarget, solidBrush, command, clipStack);
}
}
while (!clipStack.empty()) {
renderTarget.PopAxisAlignedClip();
clipStack.pop_back();
}
return true;
}
void NativeRenderer::RenderCommand(
ID2D1RenderTarget& renderTarget,
ID2D1SolidColorBrush& solidBrush,
const ::XCEngine::UI::UIDrawCommand& command,
std::vector<D2D1_RECT_F>& clipStack) {
solidBrush.SetColor(ToD2DColor(command.color));
switch (command.type) {
case ::XCEngine::UI::UIDrawCommandType::FilledRect: {
const D2D1_RECT_F rect = ToD2DRect(command.rect);
if (command.rounding > 0.0f) {
renderTarget.FillRoundedRectangle(
D2D1::RoundedRect(rect, command.rounding, command.rounding),
&solidBrush);
} else {
renderTarget.FillRectangle(rect, &solidBrush);
}
break;
}
case ::XCEngine::UI::UIDrawCommandType::RectOutline: {
const D2D1_RECT_F rect = ToD2DRect(command.rect);
const float thickness = command.thickness > 0.0f ? command.thickness : 1.0f;
if (command.rounding > 0.0f) {
renderTarget.DrawRoundedRectangle(
D2D1::RoundedRect(rect, command.rounding, command.rounding),
&solidBrush,
thickness);
} else {
renderTarget.DrawRectangle(rect, &solidBrush, thickness);
}
break;
}
case ::XCEngine::UI::UIDrawCommandType::Text: {
if (command.text.empty()) {
break;
}
const float fontSize = command.fontSize > 0.0f ? command.fontSize : 16.0f;
IDWriteTextFormat* textFormat = GetTextFormat(fontSize);
if (textFormat == nullptr) {
break;
}
const std::wstring text = Utf8ToWide(command.text);
if (text.empty()) {
break;
}
const D2D1_SIZE_F targetSize = renderTarget.GetSize();
const D2D1_RECT_F layoutRect = D2D1::RectF(
command.position.x,
command.position.y,
targetSize.width,
command.position.y + fontSize * 1.8f);
renderTarget.DrawTextW(
text.c_str(),
static_cast<UINT32>(text.size()),
textFormat,
layoutRect,
&solidBrush,
D2D1_DRAW_TEXT_OPTIONS_CLIP,
DWRITE_MEASURING_MODE_NATURAL);
break;
}
case ::XCEngine::UI::UIDrawCommandType::Image: {
if (!command.texture.IsValid()) {
break;
}
const D2D1_RECT_F rect = ToD2DRect(command.rect);
renderTarget.DrawRectangle(rect, &solidBrush, 1.0f);
break;
}
case ::XCEngine::UI::UIDrawCommandType::PushClipRect: {
const D2D1_RECT_F rect = ToD2DRect(command.rect);
renderTarget.PushAxisAlignedClip(rect, D2D1_ANTIALIAS_MODE_PER_PRIMITIVE);
clipStack.push_back(rect);
break;
}
case ::XCEngine::UI::UIDrawCommandType::PopClipRect: {
if (!clipStack.empty()) {
renderTarget.PopAxisAlignedClip();
clipStack.pop_back();
}
break;
}
default:
break;
}
}
IDWriteTextFormat* NativeRenderer::GetTextFormat(float fontSize) {
if (!m_dwriteFactory) {
return nullptr;
}
const int key = static_cast<int>(std::lround(fontSize * 10.0f));
const auto found = m_textFormats.find(key);
if (found != m_textFormats.end()) {
return found->second.Get();
}
Microsoft::WRL::ComPtr<IDWriteTextFormat> textFormat;
const HRESULT hr = m_dwriteFactory->CreateTextFormat(
L"Segoe UI",
nullptr,
DWRITE_FONT_WEIGHT_REGULAR,
DWRITE_FONT_STYLE_NORMAL,
DWRITE_FONT_STRETCH_NORMAL,
fontSize,
L"",
textFormat.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
return nullptr;
}
textFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING);
textFormat->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_NEAR);
textFormat->SetWordWrapping(DWRITE_WORD_WRAPPING_NO_WRAP);
IDWriteTextFormat* result = textFormat.Get();
m_textFormats.emplace(key, std::move(textFormat));
return result;
}
D2D1_COLOR_F NativeRenderer::ToD2DColor(const ::XCEngine::UI::UIColor& color) {
return D2D1::ColorF(color.r, color.g, color.b, color.a);
}
std::wstring NativeRenderer::Utf8ToWide(std::string_view text) {
if (text.empty()) {
return {};
}
const int sizeNeeded = MultiByteToWideChar(
CP_UTF8,
0,
text.data(),
static_cast<int>(text.size()),
nullptr,
0);
if (sizeNeeded <= 0) {
return {};
}
std::wstring wideText(static_cast<size_t>(sizeNeeded), L'\0');
MultiByteToWideChar(
CP_UTF8,
0,
text.data(),
static_cast<int>(text.size()),
wideText.data(),
sizeNeeded);
return wideText;
}
} // namespace XCEngine::Tests::CoreUI::Host

View File

@@ -0,0 +1,65 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCEngine/UI/DrawData.h>
#include <d2d1.h>
#include <dwrite.h>
#include <wincodec.h>
#include <windows.h>
#include <wrl/client.h>
#include <filesystem>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace XCEngine::Tests::CoreUI::Host {
class NativeRenderer {
public:
bool Initialize(HWND hwnd);
void Shutdown();
void Resize(UINT width, UINT height);
bool Render(const ::XCEngine::UI::UIDrawData& drawData);
bool CaptureToPng(
const ::XCEngine::UI::UIDrawData& drawData,
UINT width,
UINT height,
const std::filesystem::path& outputPath,
std::string& outError);
private:
bool EnsureRenderTarget();
bool EnsureWicFactory(std::string& outError);
void DiscardRenderTarget();
bool CreateDeviceResources();
bool RenderToTarget(
ID2D1RenderTarget& renderTarget,
ID2D1SolidColorBrush& solidBrush,
const ::XCEngine::UI::UIDrawData& drawData);
void RenderCommand(
ID2D1RenderTarget& renderTarget,
ID2D1SolidColorBrush& solidBrush,
const ::XCEngine::UI::UIDrawCommand& command,
std::vector<D2D1_RECT_F>& clipStack);
IDWriteTextFormat* GetTextFormat(float fontSize);
static D2D1_COLOR_F ToD2DColor(const ::XCEngine::UI::UIColor& color);
static std::wstring Utf8ToWide(std::string_view text);
HWND m_hwnd = nullptr;
Microsoft::WRL::ComPtr<ID2D1Factory> m_d2dFactory;
Microsoft::WRL::ComPtr<IDWriteFactory> m_dwriteFactory;
Microsoft::WRL::ComPtr<IWICImagingFactory> m_wicFactory;
Microsoft::WRL::ComPtr<ID2D1HwndRenderTarget> m_renderTarget;
Microsoft::WRL::ComPtr<ID2D1SolidColorBrush> m_solidBrush;
std::unordered_map<int, Microsoft::WRL::ComPtr<IDWriteTextFormat>> m_textFormats;
bool m_wicComInitialized = false;
};
} // namespace XCEngine::Tests::CoreUI::Host

View File

@@ -0,0 +1,65 @@
<Theme name="CoreValidationTheme">
<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.style.warm" value="#4A3426" />
<Color name="color.style.cool" value="#243C4C" />
<Color name="color.style.borderWarm" value="#D0925B" />
<Color name="color.style.borderCool" value="#6BA3C7" />
<Color name="color.style.buttonAccent" value="#4B5E7B" />
<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" />
<Spacing name="space.loose" value="18" />
<Radius name="radius.panel" value="10" />
<Radius name="radius.control" value="8" />
<Radius name="radius.loose" value="16" />
</Tokens>
<Widgets>
<Widget type="View" style="CoreWorkspace">
<Property name="background" value="color.bg.workspace" />
<Property name="padding" value="space.shell" />
</Widget>
<Widget type="Card" style="CorePanel">
<Property name="background" value="color.bg.panel" />
<Property name="radius" value="radius.panel" />
<Property name="padding" value="space.panel" />
</Widget>
<Widget type="Card" style="CoreStyleWarmCard">
<Property name="background" value="color.style.warm" />
<Property name="borderColor" value="color.style.borderWarm" />
<Property name="borderWidth" value="1" />
<Property name="padding" value="space.loose" />
<Property name="radius" value="radius.loose" />
</Widget>
<Widget type="Card" style="CoreStyleCoolCard">
<Property name="background" value="color.style.cool" />
<Property name="borderColor" value="color.style.borderCool" />
<Property name="borderWidth" value="1" />
<Property name="padding" value="space.loose" />
<Property name="radius" value="radius.loose" />
</Widget>
<Widget type="Button" style="CoreChip">
<Property name="background" value="color.bg.selection" />
<Property name="radius" value="radius.control" />
</Widget>
<Widget type="Button" style="CoreStyleAccentButton">
<Property name="background" value="color.style.buttonAccent" />
<Property name="radius" value="radius.control" />
</Widget>
<Widget type="Text" style="CoreTextMuted">
<Property name="foreground" value="color.text.muted" />
<Property name="fontSize" value="14" />
</Widget>
</Widgets>
</Theme>

View File

@@ -0,0 +1,6 @@
add_subdirectory(theme_tokens)
add_custom_target(core_ui_style_integration_tests
DEPENDS
core_ui_style_theme_tokens_validation
)

View File

@@ -0,0 +1,35 @@
set(CORE_UI_STYLE_THEME_TOKENS_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
)
add_executable(core_ui_style_theme_tokens_validation WIN32
main.cpp
${CORE_UI_STYLE_THEME_TOKENS_RESOURCES}
)
target_include_directories(core_ui_style_theme_tokens_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_style_theme_tokens_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(core_ui_style_theme_tokens_validation PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_style_theme_tokens_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_style_theme_tokens_validation PRIVATE
core_ui_integration_host
)
set_target_properties(core_ui_style_theme_tokens_validation PROPERTIES
OUTPUT_NAME "XCUICoreStyleThemeTokensValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,50 @@
<View
name="CoreStyleThemeTokens"
theme="../../shared/themes/core_validation.xctheme"
style="CoreWorkspace">
<Column padding="24" gap="16">
<Card
title="测试内容Core Style 主题 Token 与样式优先级"
subtitle="只验证 shared theme / named style / inline override不验证上层业务面板"
tone="accent"
height="108">
<Column gap="8">
<Text text="1. 检查下面三张卡片的背景、边框、圆角是否明显不同。" />
<Text text="2. Named Style Warm / Cool 应直接体现主题 TokenInline Override 应覆盖同名样式的背景色。" />
<Text text="3. 下方 Accent Button 应明显区别于默认按钮,用来确认 widget named style 已生效。" />
</Column>
</Card>
<Column gap="12">
<Card id="style-warm-card" style="CoreStyleWarmCard" title="Named Style Warm" subtitle="theme named style" height="112">
<Column gap="6">
<Text text="预期:暖色背景 + 暖色边框 + 较大的 padding。" />
</Column>
</Card>
<Card id="style-cool-card" style="CoreStyleCoolCard" title="Named Style Cool" subtitle="theme named style" height="112">
<Column gap="6">
<Text text="预期:冷色背景 + 冷色边框;应与 Warm 卡片明显区分。" />
</Column>
</Card>
<Card
id="style-inline-card"
style="CoreStyleWarmCard"
background="#6B4A2D"
borderColor="#D8A56B"
title="Inline Override"
subtitle="inline background overrides named style"
height="112">
<Column gap="6">
<Text text="预期:仍保留 Warm 卡片结构,但背景和边框应以 inline 值为准。" />
</Column>
</Card>
</Column>
<Row gap="12">
<Button id="style-default-button" text="Default Button" />
<Button id="style-accent-button" style="CoreStyleAccentButton" text="Accent Button" />
</Row>
</Column>
</View>

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"core.style.theme_tokens");
}

View File

@@ -0,0 +1,6 @@
add_subdirectory(utf8_focus_surface)
add_custom_target(core_ui_text_integration_tests
DEPENDS
core_ui_text_utf8_focus_surface_validation
)

View File

@@ -0,0 +1,35 @@
set(CORE_UI_TEXT_UTF8_FOCUS_SURFACE_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
)
add_executable(core_ui_text_utf8_focus_surface_validation WIN32
main.cpp
${CORE_UI_TEXT_UTF8_FOCUS_SURFACE_RESOURCES}
)
target_include_directories(core_ui_text_utf8_focus_surface_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_text_utf8_focus_surface_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(core_ui_text_utf8_focus_surface_validation PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_text_utf8_focus_surface_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_text_utf8_focus_surface_validation PRIVATE
core_ui_integration_host
)
set_target_properties(core_ui_text_utf8_focus_surface_validation PROPERTIES
OUTPUT_NAME "XCUICoreTextUtf8FocusSurfaceValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,43 @@
<View
name="CoreTextUtf8FocusSurface"
theme="../../shared/themes/core_validation.xctheme"
style="CoreWorkspace">
<Column padding="24" gap="16">
<Card
title="测试内容Core Text UTF-8 渲染与 textInput 焦点面"
subtitle="只验证共享文本渲染、字号差异和 textInput focus 标记;不伪造完整输入框"
tone="accent"
height="108">
<Column gap="8">
<Text text="1. 检查下面的 UTF-8 文本是否正常显示,不应出现乱码或错位。" />
<Text text="2. 按 Tab 把 focus 切到中间的 Text Input Surface右下角 Text input 应变为 active。" />
<Text text="3. 再按 Tab / Shift+Tab 离开时Text input 应恢复为 idle。" />
</Column>
</Card>
<Card title="UTF-8 Rendering" subtitle="shared text drawing only" height="144">
<Column gap="8">
<Text text="English / 中文 / UTF-8 / Delete / Backspace" />
<Text fontSize="20" text="大字号文本:删除键 Delete 与 Backspace 现在应分离。" />
<Text style="CoreTextMuted" text="多语言混排检查:层级、布局、主题、文本。" />
</Column>
</Card>
<Button id="text-focus-before" text="Focus Before" />
<Card
id="text-input-surface"
title="Text Input Surface"
subtitle="focusable + textInput marker"
focusable="true"
textInput="true"
height="132">
<Column gap="8">
<Text text="这个面板本身不是完整输入框,只用于验证 textInput focus 标记链路。" />
<Text text="当 focus 停在这里时,右下角 Text input 应显示 active。" />
</Column>
</Card>
<Button id="text-focus-after" text="Focus After" />
</Column>
</View>

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"core.text.utf8_focus_surface");
}

View File

@@ -1,21 +1,22 @@
set(CORE_UI_TEST_SOURCES
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_core_validation_registry.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_input_modifier_tracker.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_layout_engine.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_core.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_expansion_model.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_flat_hierarchy_helpers.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_input_dispatcher.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_keyboard_navigation_model.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_property_edit_model.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_selection_model.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_style_system.cpp
${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
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_layout.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_model.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
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_text_editing.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_text_input_controller.cpp
)
add_executable(core_ui_tests ${CORE_UI_TEST_SOURCES})
@@ -28,6 +29,7 @@ endif()
target_link_libraries(core_ui_tests
PRIVATE
core_ui_validation_registry
XCEngine
GTest::gtest
GTest::gtest_main
@@ -35,6 +37,7 @@ target_link_libraries(core_ui_tests
target_include_directories(core_ui_tests PRIVATE
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/tests/Fixtures
)

View File

@@ -0,0 +1,78 @@
#include <gtest/gtest.h>
#include "CoreValidationScenario.h"
#include <filesystem>
namespace {
using XCEngine::Tests::CoreUI::FindCoreValidationScenario;
using XCEngine::Tests::CoreUI::GetDefaultCoreValidationScenario;
using XCEngine::Tests::CoreUI::UIValidationDomain;
} // namespace
TEST(CoreValidationRegistryTest, KnownCoreValidationScenariosResolveToExistingResources) {
const auto* pointerScenario = FindCoreValidationScenario("core.input.pointer_states");
const auto* keyboardScenario = FindCoreValidationScenario("core.input.keyboard_focus");
const auto* scrollScenario = FindCoreValidationScenario("core.input.scroll_view");
const auto* shortcutScenario = FindCoreValidationScenario("core.input.shortcut_scope");
const auto* splitterScenario = FindCoreValidationScenario("core.layout.splitter_resize");
const auto* tabStripScenario = FindCoreValidationScenario("core.layout.tab_strip_selection");
const auto* workspaceScenario = FindCoreValidationScenario("core.layout.workspace_compose");
const auto* styleScenario = FindCoreValidationScenario("core.style.theme_tokens");
const auto* textScenario = FindCoreValidationScenario("core.text.utf8_focus_surface");
ASSERT_NE(pointerScenario, nullptr);
ASSERT_NE(keyboardScenario, nullptr);
ASSERT_NE(scrollScenario, nullptr);
ASSERT_NE(shortcutScenario, nullptr);
ASSERT_NE(splitterScenario, nullptr);
ASSERT_NE(tabStripScenario, nullptr);
ASSERT_NE(workspaceScenario, nullptr);
ASSERT_NE(styleScenario, nullptr);
ASSERT_NE(textScenario, nullptr);
EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(scrollScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(splitterScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(tabStripScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(workspaceScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(styleScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(textScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(pointerScenario->categoryId, "input");
EXPECT_EQ(keyboardScenario->categoryId, "input");
EXPECT_EQ(scrollScenario->categoryId, "input");
EXPECT_EQ(shortcutScenario->categoryId, "input");
EXPECT_EQ(splitterScenario->categoryId, "layout");
EXPECT_EQ(tabStripScenario->categoryId, "layout");
EXPECT_EQ(workspaceScenario->categoryId, "layout");
EXPECT_EQ(styleScenario->categoryId, "style");
EXPECT_EQ(textScenario->categoryId, "text");
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(scrollScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(scrollScenario->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));
EXPECT_TRUE(std::filesystem::exists(tabStripScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(tabStripScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(workspaceScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(workspaceScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(styleScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(styleScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(textScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(textScenario->themePath));
}
TEST(CoreValidationRegistryTest, DefaultScenarioPointsToKeyboardFocusBatch) {
const auto& scenario = GetDefaultCoreValidationScenario();
EXPECT_EQ(scenario.id, "core.input.keyboard_focus");
EXPECT_EQ(scenario.domain, UIValidationDomain::Core);
EXPECT_TRUE(std::filesystem::exists(scenario.documentPath));
}

View File

@@ -0,0 +1,90 @@
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <gtest/gtest.h>
#include "InputModifierTracker.h"
#include <XCEngine/UI/Types.h>
#include <windows.h>
namespace {
using XCEngine::Tests::CoreUI::Host::InputModifierTracker;
using XCEngine::UI::UIInputEventType;
TEST(CoreInputModifierTrackerTest, 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(CoreInputModifierTrackerTest, 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(CoreInputModifierTrackerTest, 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

View File

@@ -0,0 +1,130 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Layout/LayoutEngine.h>
namespace {
using XCEngine::UI::UIRect;
using XCEngine::UI::UISize;
using XCEngine::UI::Layout::ArrangeOverlayLayout;
using XCEngine::UI::Layout::ArrangeStackLayout;
using XCEngine::UI::Layout::MeasureOverlayLayout;
using XCEngine::UI::Layout::MeasureStackLayout;
using XCEngine::UI::Layout::UILayoutAlignment;
using XCEngine::UI::Layout::UILayoutAxis;
using XCEngine::UI::Layout::UILayoutConstraints;
using XCEngine::UI::Layout::UILayoutItem;
using XCEngine::UI::Layout::UILayoutLength;
using XCEngine::UI::Layout::UILayoutThickness;
using XCEngine::UI::Layout::UIOverlayLayoutOptions;
using XCEngine::UI::Layout::UIStackLayoutOptions;
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(UI_Layout, MeasureHorizontalStackAccumulatesSpacingPaddingAndCrossExtent) {
UIStackLayoutOptions options = {};
options.axis = UILayoutAxis::Horizontal;
options.spacing = 5.0f;
options.padding = UILayoutThickness::Symmetric(10.0f, 6.0f);
std::vector<UILayoutItem> items(2);
items[0].desiredContentSize = UISize(40.0f, 20.0f);
items[1].desiredContentSize = UISize(60.0f, 30.0f);
const auto result = MeasureStackLayout(options, items);
EXPECT_FLOAT_EQ(result.desiredSize.width, 125.0f);
EXPECT_FLOAT_EQ(result.desiredSize.height, 42.0f);
}
TEST(UI_Layout, ArrangeHorizontalStackDistributesRemainingSpaceToStretchChildren) {
UIStackLayoutOptions options = {};
options.axis = UILayoutAxis::Horizontal;
options.spacing = 5.0f;
options.padding = UILayoutThickness::Uniform(10.0f);
std::vector<UILayoutItem> items(3);
items[0].width = UILayoutLength::Pixels(100.0f);
items[0].desiredContentSize = UISize(10.0f, 20.0f);
items[1].width = UILayoutLength::Stretch(1.0f);
items[1].desiredContentSize = UISize(30.0f, 20.0f);
items[2].width = UILayoutLength::Pixels(50.0f);
items[2].desiredContentSize = UISize(10.0f, 20.0f);
const auto result = ArrangeStackLayout(options, items, UIRect(0.0f, 0.0f, 300.0f, 80.0f));
ExpectRect(result.children[0].arrangedRect, 10.0f, 10.0f, 100.0f, 20.0f);
ExpectRect(result.children[1].arrangedRect, 115.0f, 10.0f, 120.0f, 20.0f);
ExpectRect(result.children[2].arrangedRect, 240.0f, 10.0f, 50.0f, 20.0f);
}
TEST(UI_Layout, ArrangeVerticalStackSupportsCrossAxisStretch) {
UIStackLayoutOptions options = {};
options.axis = UILayoutAxis::Vertical;
options.spacing = 4.0f;
options.padding = UILayoutThickness::Symmetric(8.0f, 6.0f);
std::vector<UILayoutItem> items(2);
items[0].desiredContentSize = UISize(40.0f, 10.0f);
items[0].horizontalAlignment = UILayoutAlignment::Stretch;
items[1].desiredContentSize = UISize(60.0f, 20.0f);
const auto result = ArrangeStackLayout(options, items, UIRect(0.0f, 0.0f, 200.0f, 100.0f));
ExpectRect(result.children[0].arrangedRect, 8.0f, 6.0f, 184.0f, 10.0f);
ExpectRect(result.children[1].arrangedRect, 8.0f, 20.0f, 60.0f, 20.0f);
}
TEST(UI_Layout, ArrangeOverlaySupportsCenterAndStretch) {
UIOverlayLayoutOptions options = {};
options.padding = UILayoutThickness::Uniform(10.0f);
std::vector<UILayoutItem> items(2);
items[0].desiredContentSize = UISize(40.0f, 20.0f);
items[0].horizontalAlignment = UILayoutAlignment::Center;
items[0].verticalAlignment = UILayoutAlignment::Center;
items[1].desiredContentSize = UISize(10.0f, 10.0f);
items[1].width = UILayoutLength::Stretch();
items[1].height = UILayoutLength::Stretch();
items[1].margin = UILayoutThickness::Uniform(5.0f);
const auto result = ArrangeOverlayLayout(options, items, UIRect(0.0f, 0.0f, 100.0f, 60.0f));
ExpectRect(result.children[0].arrangedRect, 30.0f, 20.0f, 40.0f, 20.0f);
ExpectRect(result.children[1].arrangedRect, 15.0f, 15.0f, 70.0f, 30.0f);
}
TEST(UI_Layout, MeasureOverlayRespectsItemMinMaxAndAvailableConstraints) {
UIOverlayLayoutOptions options = {};
std::vector<UILayoutItem> items(1);
items[0].width = UILayoutLength::Pixels(500.0f);
items[0].desiredContentSize = UISize(10.0f, 10.0f);
items[0].minSize = UISize(0.0f, 50.0f);
items[0].maxSize = UISize(200.0f, 120.0f);
const auto result = MeasureOverlayLayout(
options,
items,
UILayoutConstraints::Bounded(150.0f, 100.0f));
EXPECT_FLOAT_EQ(result.children[0].measuredSize.width, 150.0f);
EXPECT_FLOAT_EQ(result.children[0].measuredSize.height, 50.0f);
EXPECT_FLOAT_EQ(result.desiredSize.width, 150.0f);
EXPECT_FLOAT_EQ(result.desiredSize.height, 50.0f);
}

View File

@@ -0,0 +1,240 @@
#include <gtest/gtest.h>
#include <XCEngine/Core/Asset/IResource.h>
#include <XCEngine/Resources/UI/UIDocuments.h>
#include <XCEngine/UI/Core/UIContext.h>
namespace {
using XCEngine::UI::HasAnyDirtyFlags;
using XCEngine::UI::IUIViewModel;
using XCEngine::UI::RevisionedViewModelBase;
using XCEngine::UI::UIBuildContext;
using XCEngine::UI::UIBuildElementDesc;
using XCEngine::UI::UIContext;
using XCEngine::UI::UIDirtyFlags;
using XCEngine::UI::UIElementChangeKind;
using XCEngine::UI::UIElementId;
using XCEngine::UI::UIElementNode;
using XCEngine::UI::UIElementTree;
using XCEngine::Resources::UIDocumentKind;
using XCEngine::Resources::UIDocumentModel;
using XCEngine::Resources::UISchema;
using XCEngine::Resources::UIView;
class TestViewModel : public RevisionedViewModelBase {
public:
void Touch() {
MarkViewModelChanged();
}
};
UIBuildElementDesc MakeElement(
UIElementId id,
const char* typeName,
std::uint64_t localStateRevision = 0,
const IUIViewModel* viewModel = nullptr,
std::uint64_t structuralRevision = 0) {
UIBuildElementDesc desc = {};
desc.id = id;
desc.typeName = typeName;
desc.localStateRevision = localStateRevision;
desc.viewModel = viewModel;
desc.structuralRevision = structuralRevision;
return desc;
}
void BuildBasicTree(UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(2, "Label")));
auto panel = buildContext.PushElement(MakeElement(3, "Panel"));
EXPECT_TRUE(static_cast<bool>(panel));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(4, "Button")));
}
const UIElementNode& RequireNode(const UIElementTree& tree, UIElementId id) {
const UIElementNode* node = tree.FindNode(id);
EXPECT_NE(node, nullptr);
return *node;
}
TEST(UICoreTest, RebuildCreatesStableParentChildTree) {
UIContext context = {};
const auto result = context.Rebuild(BuildBasicTree);
EXPECT_TRUE(result.succeeded);
EXPECT_TRUE(result.treeChanged);
EXPECT_EQ(result.generation, 1u);
EXPECT_EQ(context.GetElementTree().GetRootId(), 1u);
EXPECT_EQ(context.GetElementTree().GetNodeCount(), 4u);
EXPECT_TRUE(result.HasChange(1));
EXPECT_TRUE(result.HasChange(4));
const UIElementNode& root = RequireNode(context.GetElementTree(), 1);
ASSERT_EQ(root.childIds.size(), 2u);
EXPECT_EQ(root.childIds[0], 2u);
EXPECT_EQ(root.childIds[1], 3u);
EXPECT_EQ(root.depth, 0u);
const UIElementNode& panel = RequireNode(context.GetElementTree(), 3);
ASSERT_EQ(panel.childIds.size(), 1u);
EXPECT_EQ(panel.childIds[0], 4u);
EXPECT_EQ(panel.depth, 1u);
ASSERT_EQ(result.dirtyRootIds.size(), 1u);
EXPECT_EQ(result.dirtyRootIds[0], 1u);
}
TEST(UICoreTest, RebuildSkipsUnchangedTreeAfterDirtyFlagsAreCleared) {
UIContext context = {};
const auto initial = context.Rebuild(BuildBasicTree);
ASSERT_TRUE(initial.succeeded);
context.GetElementTree().ClearAllDirtyFlags();
const auto result = context.Rebuild(BuildBasicTree);
EXPECT_TRUE(result.succeeded);
EXPECT_FALSE(result.treeChanged);
EXPECT_TRUE(result.changes.empty());
EXPECT_TRUE(result.dirtyRootIds.empty());
}
TEST(UICoreTest, LocalStateChangeOnlyInvalidatesTheChangedLeaf) {
UIContext context = {};
ASSERT_TRUE(context.Rebuild(BuildBasicTree).succeeded);
context.GetElementTree().ClearAllDirtyFlags();
const auto result = context.Rebuild([](UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(2, "Label", 1)));
auto panel = buildContext.PushElement(MakeElement(3, "Panel"));
EXPECT_TRUE(static_cast<bool>(panel));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(4, "Button")));
});
EXPECT_TRUE(result.succeeded);
EXPECT_TRUE(result.treeChanged);
ASSERT_EQ(result.changes.size(), 1u);
ASSERT_NE(result.FindChange(2), nullptr);
EXPECT_EQ(result.FindChange(2)->kind, UIElementChangeKind::Updated);
EXPECT_TRUE(HasAnyDirtyFlags(result.FindChange(2)->dirtyFlags, UIDirtyFlags::LocalState));
EXPECT_TRUE(HasAnyDirtyFlags(result.FindChange(2)->dirtyFlags, UIDirtyFlags::Paint));
ASSERT_EQ(result.dirtyRootIds.size(), 1u);
EXPECT_EQ(result.dirtyRootIds[0], 2u);
const UIElementNode& leaf = RequireNode(context.GetElementTree(), 2);
EXPECT_TRUE(HasAnyDirtyFlags(leaf.dirtyFlags, UIDirtyFlags::LocalState));
EXPECT_FALSE(RequireNode(context.GetElementTree(), 1).IsDirty());
}
TEST(UICoreTest, ViewModelRevisionChangeInvalidatesBoundElement) {
TestViewModel viewModel = {};
UIContext context = {};
ASSERT_TRUE(context.Rebuild([&](UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(2, "Inspector", 0, &viewModel)));
}).succeeded);
context.GetElementTree().ClearAllDirtyFlags();
viewModel.Touch();
const auto result = context.Rebuild([&](UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(2, "Inspector", 0, &viewModel)));
});
EXPECT_TRUE(result.succeeded);
ASSERT_NE(result.FindChange(2), nullptr);
EXPECT_TRUE(HasAnyDirtyFlags(result.FindChange(2)->dirtyFlags, UIDirtyFlags::ViewModel));
EXPECT_TRUE(HasAnyDirtyFlags(result.FindChange(2)->dirtyFlags, UIDirtyFlags::Paint));
ASSERT_EQ(result.dirtyRootIds.size(), 1u);
EXPECT_EQ(result.dirtyRootIds[0], 2u);
}
TEST(UICoreTest, StructuralChangesBubbleLayoutInvalidationToAncestors) {
UIContext context = {};
ASSERT_TRUE(context.Rebuild([](UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
auto panel = buildContext.PushElement(MakeElement(2, "Panel"));
EXPECT_TRUE(static_cast<bool>(panel));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(3, "Text")));
}).succeeded);
context.GetElementTree().ClearAllDirtyFlags();
const auto result = context.Rebuild([](UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
auto panel = buildContext.PushElement(MakeElement(2, "Panel"));
EXPECT_TRUE(static_cast<bool>(panel));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(3, "Text")));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(4, "Icon")));
});
EXPECT_TRUE(result.succeeded);
EXPECT_TRUE(result.HasChange(4));
ASSERT_NE(result.FindChange(2), nullptr);
EXPECT_TRUE(HasAnyDirtyFlags(result.FindChange(2)->dirtyFlags, UIDirtyFlags::Structure));
const UIElementNode& root = RequireNode(context.GetElementTree(), 1);
const UIElementNode& panel = RequireNode(context.GetElementTree(), 2);
EXPECT_TRUE(HasAnyDirtyFlags(root.dirtyFlags, UIDirtyFlags::Layout));
EXPECT_TRUE(HasAnyDirtyFlags(panel.dirtyFlags, UIDirtyFlags::Structure));
ASSERT_EQ(result.dirtyRootIds.size(), 1u);
EXPECT_EQ(result.dirtyRootIds[0], 1u);
}
TEST(UICoreTest, RebuildFailsWhenElementScopesRemainOpen) {
UIContext context = {};
const auto result = context.Rebuild([](UIBuildContext& buildContext) {
EXPECT_TRUE(buildContext.BeginElement(MakeElement(1, "Root")));
});
EXPECT_FALSE(result.succeeded);
EXPECT_FALSE(result.errorMessage.empty());
EXPECT_EQ(context.GetElementTree().GetNodeCount(), 0u);
}
TEST(UICoreTest, UIDocumentResourcesAcceptMovedDocumentModels) {
UIDocumentModel viewDocument = {};
viewDocument.kind = UIDocumentKind::View;
viewDocument.sourcePath = "Assets/UI/Test.xcui";
viewDocument.displayName = "TestView";
viewDocument.rootNode.tagName = "View";
viewDocument.valid = true;
UIView view = {};
XCEngine::Resources::IResource::ConstructParams params = {};
params.name = "TestView";
params.path = viewDocument.sourcePath;
params.guid = XCEngine::Resources::ResourceGUID::Generate(params.path);
view.Initialize(params);
view.SetDocumentModel(std::move(viewDocument));
EXPECT_EQ(view.GetRootNode().tagName, "View");
EXPECT_EQ(view.GetSourcePath(), "Assets/UI/Test.xcui");
UIDocumentModel schemaDocument = {};
schemaDocument.kind = UIDocumentKind::Schema;
schemaDocument.sourcePath = "Assets/UI/Test.xcschema";
schemaDocument.displayName = "TestSchema";
schemaDocument.rootNode.tagName = "Schema";
schemaDocument.schemaDefinition.name = "TestSchema";
schemaDocument.schemaDefinition.valid = true;
schemaDocument.valid = true;
UISchema schema = {};
params.name = "TestSchema";
params.path = schemaDocument.sourcePath;
params.guid = XCEngine::Resources::ResourceGUID::Generate(params.path);
schema.Initialize(params);
schema.SetDocumentModel(std::move(schemaDocument));
EXPECT_TRUE(schema.GetSchemaDefinition().valid);
EXPECT_EQ(schema.GetSchemaDefinition().name, "TestSchema");
}
} // namespace

View File

@@ -0,0 +1,45 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIExpansionModel.h>
namespace {
using XCEngine::UI::Widgets::UIExpansionModel;
TEST(UIExpansionModelTest, ExpandCollapseAndClearTrackExpandedItems) {
UIExpansionModel expansion = {};
EXPECT_FALSE(expansion.HasExpandedItems());
EXPECT_EQ(expansion.GetExpandedCount(), 0u);
EXPECT_TRUE(expansion.Expand("treeAssetsRoot"));
EXPECT_TRUE(expansion.IsExpanded("treeAssetsRoot"));
EXPECT_TRUE(expansion.HasExpandedItems());
EXPECT_EQ(expansion.GetExpandedCount(), 1u);
EXPECT_FALSE(expansion.Expand("treeAssetsRoot"));
EXPECT_TRUE(expansion.Collapse("treeAssetsRoot"));
EXPECT_FALSE(expansion.IsExpanded("treeAssetsRoot"));
EXPECT_EQ(expansion.GetExpandedCount(), 0u);
EXPECT_FALSE(expansion.Collapse("treeAssetsRoot"));
EXPECT_FALSE(expansion.Clear());
}
TEST(UIExpansionModelTest, SetAndToggleExpandedReplaceStateForMatchingItem) {
UIExpansionModel expansion = {};
EXPECT_TRUE(expansion.SetExpanded("inspectorTransform", true));
EXPECT_TRUE(expansion.IsExpanded("inspectorTransform"));
EXPECT_EQ(expansion.GetExpandedCount(), 1u);
EXPECT_FALSE(expansion.SetExpanded("inspectorTransform", true));
EXPECT_TRUE(expansion.ToggleExpanded("inspectorTransform"));
EXPECT_FALSE(expansion.IsExpanded("inspectorTransform"));
EXPECT_TRUE(expansion.ToggleExpanded("inspectorMesh"));
EXPECT_TRUE(expansion.IsExpanded("inspectorMesh"));
EXPECT_TRUE(expansion.SetExpanded("inspectorMesh", false));
EXPECT_FALSE(expansion.IsExpanded("inspectorMesh"));
}
} // namespace

View File

@@ -0,0 +1,145 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIFlatHierarchyHelpers.h>
#include <array>
#include <unordered_set>
#include <vector>
namespace {
using XCEngine::UI::Widgets::kInvalidUIFlatHierarchyItemOffset;
using XCEngine::UI::Widgets::UIFlatHierarchyFindFirstVisibleChildOffset;
using XCEngine::UI::Widgets::UIFlatHierarchyFindParentOffset;
using XCEngine::UI::Widgets::UIFlatHierarchyHasChildren;
using XCEngine::UI::Widgets::UIFlatHierarchyIsVisible;
struct FlatHierarchyItem {
float depth = 0.0f;
};
TEST(UIFlatHierarchyHelpersTest, HasChildrenAndParentResolutionTrackIndentedBranches) {
const std::array<FlatHierarchyItem, 5> items = {{
{ 0.0f },
{ 1.0f },
{ 2.0f },
{ 1.0f },
{ 0.0f },
}};
const std::vector<std::size_t> itemIndices = { 0u, 1u, 2u, 3u, 4u };
const auto resolveDepth = [&items](std::size_t itemIndex) {
return items[itemIndex].depth;
};
EXPECT_TRUE(UIFlatHierarchyHasChildren(itemIndices, 0u, resolveDepth));
EXPECT_TRUE(UIFlatHierarchyHasChildren(itemIndices, 1u, resolveDepth));
EXPECT_FALSE(UIFlatHierarchyHasChildren(itemIndices, 2u, resolveDepth));
EXPECT_FALSE(UIFlatHierarchyHasChildren(itemIndices, 3u, resolveDepth));
EXPECT_FALSE(UIFlatHierarchyHasChildren(itemIndices, 4u, resolveDepth));
EXPECT_EQ(
UIFlatHierarchyFindParentOffset(itemIndices, 0u, resolveDepth),
kInvalidUIFlatHierarchyItemOffset);
EXPECT_EQ(UIFlatHierarchyFindParentOffset(itemIndices, 1u, resolveDepth), 0u);
EXPECT_EQ(UIFlatHierarchyFindParentOffset(itemIndices, 2u, resolveDepth), 1u);
EXPECT_EQ(UIFlatHierarchyFindParentOffset(itemIndices, 3u, resolveDepth), 0u);
EXPECT_EQ(
UIFlatHierarchyFindParentOffset(itemIndices, 99u, resolveDepth),
kInvalidUIFlatHierarchyItemOffset);
}
TEST(UIFlatHierarchyHelpersTest, VisibilityFollowsCollapsedAncestorStateAcrossDepthTransitions) {
const std::array<FlatHierarchyItem, 4> items = {{
{ 0.0f },
{ 1.0f },
{ 2.0f },
{ 1.0f },
}};
const std::vector<std::size_t> itemIndices = { 0u, 1u, 2u, 3u };
const auto resolveDepth = [&items](std::size_t itemIndex) {
return items[itemIndex].depth;
};
std::unordered_set<std::size_t> expandedItems = { 0u };
const auto isExpanded = [&expandedItems](std::size_t itemIndex) {
return expandedItems.contains(itemIndex);
};
EXPECT_TRUE(UIFlatHierarchyIsVisible(itemIndices, 0u, resolveDepth, isExpanded));
EXPECT_TRUE(UIFlatHierarchyIsVisible(itemIndices, 1u, resolveDepth, isExpanded));
EXPECT_FALSE(UIFlatHierarchyIsVisible(itemIndices, 2u, resolveDepth, isExpanded));
EXPECT_TRUE(UIFlatHierarchyIsVisible(itemIndices, 3u, resolveDepth, isExpanded));
expandedItems.insert(1u);
EXPECT_TRUE(UIFlatHierarchyIsVisible(itemIndices, 2u, resolveDepth, isExpanded));
expandedItems.clear();
EXPECT_FALSE(UIFlatHierarchyIsVisible(itemIndices, 1u, resolveDepth, isExpanded));
EXPECT_FALSE(UIFlatHierarchyIsVisible(itemIndices, 2u, resolveDepth, isExpanded));
}
TEST(UIFlatHierarchyHelpersTest, FirstVisibleChildSkipsCollapsedDescendantsUntilExpanded) {
const std::array<FlatHierarchyItem, 4> items = {{
{ 0.0f },
{ 1.0f },
{ 2.0f },
{ 1.0f },
}};
const std::vector<std::size_t> itemIndices = { 0u, 1u, 2u, 3u };
const auto resolveDepth = [&items](std::size_t itemIndex) {
return items[itemIndex].depth;
};
std::unordered_set<std::size_t> expandedItems = { 0u };
const auto isExpanded = [&expandedItems](std::size_t itemIndex) {
return expandedItems.contains(itemIndex);
};
const auto isVisible = [&itemIndices, &resolveDepth, &isExpanded](std::size_t itemIndex) {
for (std::size_t itemOffset = 0; itemOffset < itemIndices.size(); ++itemOffset) {
if (itemIndices[itemOffset] == itemIndex) {
return UIFlatHierarchyIsVisible(itemIndices, itemOffset, resolveDepth, isExpanded);
}
}
return false;
};
EXPECT_EQ(
UIFlatHierarchyFindFirstVisibleChildOffset(itemIndices, 0u, resolveDepth, isVisible),
1u);
EXPECT_EQ(
UIFlatHierarchyFindFirstVisibleChildOffset(itemIndices, 1u, resolveDepth, isVisible),
kInvalidUIFlatHierarchyItemOffset);
expandedItems.insert(1u);
EXPECT_EQ(
UIFlatHierarchyFindFirstVisibleChildOffset(itemIndices, 1u, resolveDepth, isVisible),
2u);
}
TEST(UIFlatHierarchyHelpersTest, NegativeDepthsClampToRootsForHierarchyQueries) {
const std::array<FlatHierarchyItem, 3> items = {{
{ -3.0f },
{ 1.0f },
{ -1.0f },
}};
const std::vector<std::size_t> itemIndices = { 0u, 1u, 2u };
const auto resolveDepth = [&items](std::size_t itemIndex) {
return items[itemIndex].depth;
};
const auto isExpanded = [](std::size_t itemIndex) {
return itemIndex == 0u;
};
EXPECT_TRUE(UIFlatHierarchyHasChildren(itemIndices, 0u, resolveDepth));
EXPECT_EQ(UIFlatHierarchyFindParentOffset(itemIndices, 1u, resolveDepth), 0u);
EXPECT_EQ(
UIFlatHierarchyFindParentOffset(itemIndices, 2u, resolveDepth),
kInvalidUIFlatHierarchyItemOffset);
EXPECT_TRUE(UIFlatHierarchyIsVisible(itemIndices, 2u, resolveDepth, isExpanded));
}
} // namespace

View File

@@ -0,0 +1,146 @@
#include <gtest/gtest.h>
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/Input/UIInputDispatcher.h>
#include <algorithm>
#include <vector>
namespace {
using XCEngine::UI::UIInputDispatchRequest;
using XCEngine::UI::UIInputDispatcher;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIInputPath;
using XCEngine::UI::UIPointerButton;
using XCEngine::Input::KeyCode;
UIInputEvent MakePointerEvent(
UIInputEventType type,
UIPointerButton button = UIPointerButton::None) {
UIInputEvent event = {};
event.type = type;
event.pointerButton = button;
return event;
}
} // namespace
TEST(UIInputDispatcherTest, PointerDownTransfersFocusAndStartsActivePath) {
UIInputDispatcher dispatcher{};
const UIInputPath hoveredPath = { 10u, 20u, 30u };
std::vector<UIInputDispatchRequest> routedRequests = {};
const auto summary = dispatcher.Dispatch(
MakePointerEvent(UIInputEventType::PointerButtonDown, UIPointerButton::Left),
hoveredPath,
[&](const UIInputDispatchRequest& request) {
routedRequests.push_back(request);
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_TRUE(summary.focusChange.Changed());
EXPECT_EQ(summary.focusChange.previousPath, UIInputPath());
EXPECT_EQ(summary.focusChange.currentPath, hoveredPath);
EXPECT_EQ(dispatcher.GetFocusController().GetFocusedPath(), hoveredPath);
EXPECT_EQ(dispatcher.GetFocusController().GetActivePath(), hoveredPath);
ASSERT_FALSE(routedRequests.empty());
EXPECT_EQ(summary.routing.plan.targetPath, hoveredPath);
EXPECT_EQ(summary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Hovered);
const auto targetIt = std::find_if(
routedRequests.begin(),
routedRequests.end(),
[](const UIInputDispatchRequest& request) {
return request.isTargetElement;
});
ASSERT_NE(targetIt, routedRequests.end());
EXPECT_EQ(targetIt->elementId, hoveredPath.Target());
}
TEST(UIInputDispatcherTest, PointerCaptureOverridesHoveredRouteForPointerEvents) {
UIInputDispatcher dispatcher{};
const UIInputPath hoveredPath = { 41u, 42u };
const UIInputPath capturePath = { 7u, 8u, 9u };
dispatcher.GetFocusController().SetPointerCapturePath(capturePath);
const auto summary = dispatcher.Dispatch(
MakePointerEvent(UIInputEventType::PointerMove),
hoveredPath,
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_EQ(summary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Captured);
EXPECT_EQ(summary.routing.plan.targetPath, capturePath);
}
TEST(UIInputDispatcherTest, PointerButtonUpClearsActivePathAfterDispatch) {
UIInputDispatcher dispatcher{};
const UIInputPath activePath = { 2u, 4u, 6u };
dispatcher.GetFocusController().SetActivePath(activePath);
const auto summary = dispatcher.Dispatch(
MakePointerEvent(UIInputEventType::PointerButtonUp, UIPointerButton::Left),
{},
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_FALSE(summary.routing.handled);
EXPECT_FALSE(dispatcher.GetFocusController().HasActivePath());
}
TEST(UIInputDispatcherTest, KeyboardEventsRouteToFocusedPath) {
UIInputDispatcher dispatcher{};
const UIInputPath focusedPath = { 101u, 202u };
dispatcher.GetFocusController().SetFocusedPath(focusedPath);
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = 'F';
const auto summary = dispatcher.Dispatch(
event,
{},
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_EQ(summary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Focused);
EXPECT_EQ(summary.routing.plan.targetPath, focusedPath);
}
TEST(UIInputDispatcherTest, KeyboardActivationStartsAndClearsActivePathOnFocusedTarget) {
UIInputDispatcher dispatcher{};
const UIInputPath focusedPath = { 301u, 302u, 303u };
dispatcher.GetFocusController().SetFocusedPath(focusedPath);
UIInputEvent keyDown = {};
keyDown.type = UIInputEventType::KeyDown;
keyDown.keyCode = static_cast<std::int32_t>(KeyCode::Enter);
const auto downSummary = dispatcher.Dispatch(
keyDown,
{},
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_EQ(downSummary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Focused);
EXPECT_EQ(downSummary.routing.plan.targetPath, focusedPath);
EXPECT_EQ(dispatcher.GetFocusController().GetActivePath(), focusedPath);
UIInputEvent keyUp = {};
keyUp.type = UIInputEventType::KeyUp;
keyUp.keyCode = static_cast<std::int32_t>(KeyCode::Enter);
dispatcher.Dispatch(
keyUp,
{},
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_FALSE(dispatcher.GetFocusController().HasActivePath());
}

View File

@@ -0,0 +1,101 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIKeyboardNavigationModel.h>
namespace {
using XCEngine::UI::Widgets::UIKeyboardNavigationModel;
TEST(UIKeyboardNavigationModelTest, EmptyModelStartsWithoutCurrentIndexOrAnchor) {
UIKeyboardNavigationModel navigation = {};
EXPECT_EQ(navigation.GetItemCount(), 0u);
EXPECT_FALSE(navigation.HasCurrentIndex());
EXPECT_EQ(navigation.GetCurrentIndex(), UIKeyboardNavigationModel::InvalidIndex);
EXPECT_FALSE(navigation.HasSelectionAnchor());
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), UIKeyboardNavigationModel::InvalidIndex);
EXPECT_FALSE(navigation.MoveNext());
EXPECT_FALSE(navigation.MovePrevious());
EXPECT_FALSE(navigation.MoveHome());
EXPECT_FALSE(navigation.MoveEnd());
}
TEST(UIKeyboardNavigationModelTest, SetCurrentIndexAndDirectionalMovesTrackCurrentIndexAndAnchor) {
UIKeyboardNavigationModel navigation = {};
ASSERT_TRUE(navigation.SetItemCount(4u));
EXPECT_TRUE(navigation.SetCurrentIndex(1u));
EXPECT_EQ(navigation.GetCurrentIndex(), 1u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 1u);
EXPECT_TRUE(navigation.MoveNext());
EXPECT_EQ(navigation.GetCurrentIndex(), 2u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 2u);
EXPECT_TRUE(navigation.MoveEnd());
EXPECT_EQ(navigation.GetCurrentIndex(), 3u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 3u);
EXPECT_FALSE(navigation.MoveNext());
EXPECT_TRUE(navigation.MoveHome());
EXPECT_EQ(navigation.GetCurrentIndex(), 0u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 0u);
EXPECT_FALSE(navigation.MovePrevious());
}
TEST(UIKeyboardNavigationModelTest, MovePreviousAndEndSeedNavigationWhenCurrentIndexIsUnset) {
UIKeyboardNavigationModel navigation = {};
ASSERT_TRUE(navigation.SetItemCount(5u));
EXPECT_TRUE(navigation.MovePrevious());
EXPECT_EQ(navigation.GetCurrentIndex(), 4u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 4u);
EXPECT_TRUE(navigation.ClearCurrentIndex());
EXPECT_TRUE(navigation.ClearSelectionAnchor());
EXPECT_FALSE(navigation.HasCurrentIndex());
EXPECT_FALSE(navigation.HasSelectionAnchor());
EXPECT_TRUE(navigation.MoveEnd());
EXPECT_EQ(navigation.GetCurrentIndex(), 4u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 4u);
}
TEST(UIKeyboardNavigationModelTest, ExplicitAnchorCanBePreservedUntilNavigationCollapsesIt) {
UIKeyboardNavigationModel navigation = {};
ASSERT_TRUE(navigation.SetItemCount(6u));
EXPECT_TRUE(navigation.SetSelectionAnchorIndex(1u));
EXPECT_TRUE(navigation.SetCurrentIndex(4u, false));
EXPECT_EQ(navigation.GetCurrentIndex(), 4u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 1u);
EXPECT_TRUE(navigation.MovePrevious());
EXPECT_EQ(navigation.GetCurrentIndex(), 3u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 3u);
}
TEST(UIKeyboardNavigationModelTest, ItemCountChangesClampCurrentIndexAndSelectionAnchor) {
UIKeyboardNavigationModel navigation = {};
ASSERT_TRUE(navigation.SetItemCount(5u));
ASSERT_TRUE(navigation.SetSelectionAnchorIndex(3u));
ASSERT_TRUE(navigation.SetCurrentIndex(4u, false));
EXPECT_TRUE(navigation.SetItemCount(4u));
EXPECT_EQ(navigation.GetCurrentIndex(), 3u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 3u);
EXPECT_FALSE(navigation.SetCurrentIndex(3u, false));
EXPECT_TRUE(navigation.SetSelectionAnchorIndex(2u));
EXPECT_TRUE(navigation.SetItemCount(2u));
EXPECT_EQ(navigation.GetCurrentIndex(), 1u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 1u);
EXPECT_TRUE(navigation.SetItemCount(0u));
EXPECT_FALSE(navigation.HasCurrentIndex());
EXPECT_EQ(navigation.GetCurrentIndex(), UIKeyboardNavigationModel::InvalidIndex);
EXPECT_FALSE(navigation.HasSelectionAnchor());
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), UIKeyboardNavigationModel::InvalidIndex);
}
} // namespace

View File

@@ -0,0 +1,80 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIPropertyEditModel.h>
namespace {
using XCEngine::UI::Widgets::UIPropertyEditModel;
TEST(UIPropertyEditModelTest, BeginEditTracksActiveFieldAndInitialValue) {
UIPropertyEditModel model = {};
EXPECT_FALSE(model.HasActiveEdit());
EXPECT_TRUE(model.GetActiveFieldId().empty());
EXPECT_TRUE(model.GetStagedValue().empty());
EXPECT_FALSE(model.IsDirty());
EXPECT_FALSE(model.BeginEdit("", "12.0"));
EXPECT_TRUE(model.BeginEdit("transform.position.x", "12.0"));
EXPECT_TRUE(model.HasActiveEdit());
EXPECT_EQ(model.GetActiveFieldId(), "transform.position.x");
EXPECT_EQ(model.GetStagedValue(), "12.0");
EXPECT_FALSE(model.IsDirty());
EXPECT_FALSE(model.BeginEdit("transform.position.x", "12.0"));
}
TEST(UIPropertyEditModelTest, UpdateStagedValueTracksDirtyAgainstBaseline) {
UIPropertyEditModel model = {};
EXPECT_FALSE(model.UpdateStagedValue("3.5"));
ASSERT_TRUE(model.BeginEdit("light.intensity", "1.0"));
EXPECT_TRUE(model.UpdateStagedValue("3.5"));
EXPECT_EQ(model.GetStagedValue(), "3.5");
EXPECT_TRUE(model.IsDirty());
EXPECT_FALSE(model.UpdateStagedValue("3.5"));
EXPECT_TRUE(model.UpdateStagedValue("1.0"));
EXPECT_EQ(model.GetStagedValue(), "1.0");
EXPECT_FALSE(model.IsDirty());
}
TEST(UIPropertyEditModelTest, CommitEditReturnsPayloadAndClearsState) {
UIPropertyEditModel model = {};
ASSERT_TRUE(model.BeginEdit("material.albedo", "#ffffff"));
ASSERT_TRUE(model.UpdateStagedValue("#ffcc00"));
std::string committedFieldId = {};
std::string committedValue = {};
EXPECT_TRUE(model.CommitEdit(&committedFieldId, &committedValue));
EXPECT_EQ(committedFieldId, "material.albedo");
EXPECT_EQ(committedValue, "#ffcc00");
EXPECT_FALSE(model.HasActiveEdit());
EXPECT_TRUE(model.GetActiveFieldId().empty());
EXPECT_TRUE(model.GetStagedValue().empty());
EXPECT_FALSE(model.IsDirty());
EXPECT_FALSE(model.CommitEdit(&committedFieldId, &committedValue));
}
TEST(UIPropertyEditModelTest, CancelEditDropsStagedChangesAndResetsSession) {
UIPropertyEditModel model = {};
ASSERT_TRUE(model.BeginEdit("camera.fov", "60"));
ASSERT_TRUE(model.UpdateStagedValue("75"));
ASSERT_TRUE(model.IsDirty());
EXPECT_TRUE(model.CancelEdit());
EXPECT_FALSE(model.HasActiveEdit());
EXPECT_TRUE(model.GetActiveFieldId().empty());
EXPECT_TRUE(model.GetStagedValue().empty());
EXPECT_FALSE(model.IsDirty());
EXPECT_FALSE(model.CancelEdit());
EXPECT_TRUE(model.BeginEdit("camera.nearClip", "0.3"));
EXPECT_EQ(model.GetActiveFieldId(), "camera.nearClip");
EXPECT_EQ(model.GetStagedValue(), "0.3");
EXPECT_FALSE(model.IsDirty());
}
} // namespace

View File

@@ -0,0 +1,42 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UISelectionModel.h>
namespace {
using XCEngine::UI::Widgets::UISelectionModel;
TEST(UISelectionModelTest, SetAndClearSelectionTrackCurrentId) {
UISelectionModel selection = {};
EXPECT_FALSE(selection.HasSelection());
EXPECT_TRUE(selection.GetSelectedId().empty());
EXPECT_TRUE(selection.SetSelection("assetLighting"));
EXPECT_TRUE(selection.HasSelection());
EXPECT_TRUE(selection.IsSelected("assetLighting"));
EXPECT_EQ(selection.GetSelectedId(), "assetLighting");
EXPECT_FALSE(selection.SetSelection("assetLighting"));
EXPECT_TRUE(selection.ClearSelection());
EXPECT_FALSE(selection.HasSelection());
EXPECT_TRUE(selection.GetSelectedId().empty());
EXPECT_FALSE(selection.ClearSelection());
}
TEST(UISelectionModelTest, ToggleSelectionSelectsAndDeselectsMatchingId) {
UISelectionModel selection = {};
EXPECT_TRUE(selection.ToggleSelection("treeScenes"));
EXPECT_EQ(selection.GetSelectedId(), "treeScenes");
EXPECT_TRUE(selection.ToggleSelection("treeScenes"));
EXPECT_TRUE(selection.GetSelectedId().empty());
EXPECT_TRUE(selection.ToggleSelection("treeMaterials"));
EXPECT_EQ(selection.GetSelectedId(), "treeMaterials");
EXPECT_TRUE(selection.ToggleSelection("treeUi"));
EXPECT_EQ(selection.GetSelectedId(), "treeUi");
}
} // namespace

View File

@@ -0,0 +1,341 @@
#include <gtest/gtest.h>
#include <XCEngine/Resources/UI/UIDocumentTypes.h>
#include <XCEngine/UI/Style/DocumentStyleCompiler.h>
#include <XCEngine/UI/Style/StyleResolver.h>
namespace {
using XCEngine::Math::Color;
using XCEngine::Resources::UIDocumentAttribute;
using XCEngine::Resources::UIDocumentKind;
using XCEngine::Resources::UIDocumentModel;
using XCEngine::Resources::UIDocumentNode;
using XCEngine::UI::Style::BuildBuiltinTheme;
using XCEngine::UI::Style::BuildInlineStyle;
using XCEngine::UI::Style::BuildTheme;
using XCEngine::UI::Style::CompileDocumentStyle;
using XCEngine::UI::Style::UICornerRadius;
using XCEngine::UI::Style::UIBuiltinThemeKind;
using XCEngine::UI::Style::UIResolvedStyle;
using XCEngine::UI::Style::UIStyleLayer;
using XCEngine::UI::Style::UIStylePropertyId;
using XCEngine::UI::Style::UIStyleResolveContext;
using XCEngine::UI::Style::UIStyleSet;
using XCEngine::UI::Style::UIStyleSheet;
using XCEngine::UI::Style::UIStyleValue;
using XCEngine::UI::Style::UIStyleValueType;
using XCEngine::UI::Style::UITheme;
using XCEngine::UI::Style::UIThemeDefinition;
using XCEngine::UI::Style::UITokenResolveStatus;
void ExpectColorEq(const Color& actual, const Color& expected) {
EXPECT_FLOAT_EQ(actual.r, expected.r);
EXPECT_FLOAT_EQ(actual.g, expected.g);
EXPECT_FLOAT_EQ(actual.b, expected.b);
EXPECT_FLOAT_EQ(actual.a, expected.a);
}
UIDocumentAttribute MakeAttribute(const char* name, const char* value) {
UIDocumentAttribute attribute = {};
attribute.name = name;
attribute.value = value;
return attribute;
}
UIDocumentNode MakeNode(
const char* tagName,
std::initializer_list<std::pair<const char*, const char*>> attributes = {},
std::initializer_list<UIDocumentNode> children = {}) {
UIDocumentNode node = {};
node.tagName = tagName;
for (const auto& [name, value] : attributes) {
node.attributes.PushBack(MakeAttribute(name, value));
}
for (const UIDocumentNode& child : children) {
node.children.PushBack(child);
}
return node;
}
UIDocumentModel MakeThemeDocument() {
UIDocumentModel document = {};
document.kind = UIDocumentKind::Theme;
document.valid = true;
document.rootNode = MakeNode(
"Theme",
{ { "name", "ValidationTheme" } },
{
MakeNode(
"Tokens",
{},
{
MakeNode("Color", { { "name", "color.surface" }, { "value", "#202224" } }),
MakeNode("Color", { { "name", "color.border" }, { "value", "#45484C" } }),
MakeNode("Spacing", { { "name", "space.panel" }, { "value", "12" } }),
MakeNode("Radius", { { "name", "radius.card" }, { "value", "10" } })
}),
MakeNode(
"Widgets",
{},
{
MakeNode(
"Widget",
{ { "type", "Card" } },
{
MakeNode("Property", { { "name", "borderColor" }, { "value", "color.border" } }),
MakeNode("Property", { { "name", "borderWidth" }, { "value", "1" } })
}),
MakeNode(
"Widget",
{ { "type", "Card" }, { "style", "ValidationPanel" } },
{
MakeNode("Property", { { "name", "background" }, { "value", "color.surface" } }),
MakeNode("Property", { { "name", "padding" }, { "value", "space.panel" } }),
MakeNode("Property", { { "name", "radius" }, { "value", "radius.card" } })
}),
MakeNode(
"Widget",
{ { "type", "Text" } },
{
MakeNode("Property", { { "name", "foreground" }, { "value", "#E8E8E8" } }),
MakeNode("Property", { { "name", "fontSize" }, { "value", "15" } })
})
})
});
return document;
}
TEST(UI_StyleSystem, ThemeDefinitionBuildsThemeAndResolvesAliases) {
UIThemeDefinition definition = {};
definition.name = "CustomTheme";
definition.SetToken("space.base", UIStyleValue(8.0f));
definition.SetToken("gap.control", UIStyleValue::Token("space.base"));
definition.SetToken("radius.card", UIStyleValue(UICornerRadius::Uniform(12.0f)));
const UITheme theme = BuildTheme(definition);
EXPECT_EQ(theme.GetName(), "CustomTheme");
const auto gapToken = theme.ResolveToken("gap.control", UIStyleValueType::Float);
ASSERT_EQ(gapToken.status, UITokenResolveStatus::Resolved);
ASSERT_NE(gapToken.value.TryGetFloat(), nullptr);
EXPECT_FLOAT_EQ(*gapToken.value.TryGetFloat(), 8.0f);
const auto radiusToken = theme.ResolveToken("radius.card", UIStyleValueType::CornerRadius);
ASSERT_EQ(radiusToken.status, UITokenResolveStatus::Resolved);
ASSERT_NE(radiusToken.value.TryGetCornerRadius(), nullptr);
EXPECT_TRUE(radiusToken.value.TryGetCornerRadius()->IsUniform());
EXPECT_FLOAT_EQ(radiusToken.value.TryGetCornerRadius()->topLeft, 12.0f);
}
TEST(UI_StyleSystem, ThemeResolvesParentTokensAndBuiltinVariantsDiffer) {
UIThemeDefinition baseDefinition = {};
baseDefinition.name = "Base";
baseDefinition.SetToken("color.surface", UIStyleValue(Color(0.20f, 0.21f, 0.22f, 1.0f)));
const UITheme baseTheme = BuildTheme(baseDefinition);
UIThemeDefinition childDefinition = {};
childDefinition.name = "Child";
childDefinition.SetToken("font.body", UIStyleValue(15.0f));
const UITheme childTheme = BuildTheme(childDefinition, &baseTheme);
const auto parentToken = childTheme.ResolveToken("color.surface", UIStyleValueType::Color);
ASSERT_EQ(parentToken.status, UITokenResolveStatus::Resolved);
ASSERT_NE(parentToken.value.TryGetColor(), nullptr);
ExpectColorEq(*parentToken.value.TryGetColor(), Color(0.20f, 0.21f, 0.22f, 1.0f));
const UITheme darkTheme = BuildBuiltinTheme(UIBuiltinThemeKind::NeutralDark);
const UITheme lightTheme = BuildBuiltinTheme(UIBuiltinThemeKind::NeutralLight);
const auto darkSurface = darkTheme.ResolveToken("color.surface", UIStyleValueType::Color);
const auto lightSurface = lightTheme.ResolveToken("color.surface", UIStyleValueType::Color);
ASSERT_EQ(darkSurface.status, UITokenResolveStatus::Resolved);
ASSERT_EQ(lightSurface.status, UITokenResolveStatus::Resolved);
ASSERT_NE(darkSurface.value.TryGetColor(), nullptr);
ASSERT_NE(lightSurface.value.TryGetColor(), nullptr);
EXPECT_NE(darkSurface.value.TryGetColor()->r, lightSurface.value.TryGetColor()->r);
}
TEST(UI_StyleSystem, ThemeReportsMissingCyclesAndTypeMismatches) {
UIThemeDefinition definition = {};
definition.SetToken("cycle.a", UIStyleValue::Token("cycle.b"));
definition.SetToken("cycle.b", UIStyleValue::Token("cycle.a"));
definition.SetToken("color.surface", UIStyleValue(Color(0.1f, 0.2f, 0.3f, 1.0f)));
const UITheme theme = BuildTheme(definition);
EXPECT_EQ(
theme.ResolveToken("missing.token", UIStyleValueType::Float).status,
UITokenResolveStatus::MissingToken);
EXPECT_EQ(
theme.ResolveToken("cycle.a", UIStyleValueType::Float).status,
UITokenResolveStatus::CycleDetected);
EXPECT_EQ(
theme.ResolveToken("color.surface", UIStyleValueType::Float).status,
UITokenResolveStatus::TypeMismatch);
}
TEST(UI_StyleSystem, StyleResolutionPrefersLocalThenNamedThenTypeThenDefault) {
UIStyleSheet styleSheet = {};
styleSheet.DefaultStyle().SetProperty(UIStylePropertyId::FontSize, UIStyleValue(12.0f));
styleSheet.GetOrCreateTypeStyle("Button").SetProperty(UIStylePropertyId::FontSize, UIStyleValue(14.0f));
styleSheet.GetOrCreateNamedStyle("Primary").SetProperty(UIStylePropertyId::FontSize, UIStyleValue(16.0f));
UIStyleSet localStyle = {};
localStyle.SetProperty(UIStylePropertyId::FontSize, UIStyleValue(18.0f));
UIStyleResolveContext context = {};
context.styleSheet = &styleSheet;
context.selector.typeName = "Button";
context.selector.styleName = "Primary";
context.localStyle = &localStyle;
auto resolution = XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::FontSize, context);
ASSERT_TRUE(resolution.resolved);
EXPECT_EQ(resolution.layer, UIStyleLayer::Local);
ASSERT_NE(resolution.value.TryGetFloat(), nullptr);
EXPECT_FLOAT_EQ(*resolution.value.TryGetFloat(), 18.0f);
localStyle.RemoveProperty(UIStylePropertyId::FontSize);
resolution = XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::FontSize, context);
ASSERT_TRUE(resolution.resolved);
EXPECT_EQ(resolution.layer, UIStyleLayer::Named);
EXPECT_FLOAT_EQ(*resolution.value.TryGetFloat(), 16.0f);
styleSheet.GetOrCreateNamedStyle("Primary").RemoveProperty(UIStylePropertyId::FontSize);
resolution = XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::FontSize, context);
ASSERT_TRUE(resolution.resolved);
EXPECT_EQ(resolution.layer, UIStyleLayer::Type);
EXPECT_FLOAT_EQ(*resolution.value.TryGetFloat(), 14.0f);
}
TEST(UI_StyleSystem, StyleResolutionFallsBackWhenHigherPriorityTokenCannotResolve) {
UIThemeDefinition themeDefinition = {};
themeDefinition.SetToken("color.accent", UIStyleValue(Color(0.90f, 0.20f, 0.10f, 1.0f)));
const UITheme theme = BuildTheme(themeDefinition);
UIStyleSheet styleSheet = {};
styleSheet.DefaultStyle().SetProperty(UIStylePropertyId::BorderWidth, UIStyleValue(1.0f));
styleSheet.GetOrCreateTypeStyle("Button")
.SetProperty(UIStylePropertyId::BackgroundColor, UIStyleValue::Token("color.accent"));
styleSheet.GetOrCreateNamedStyle("Danger")
.SetProperty(UIStylePropertyId::BackgroundColor, UIStyleValue::Token("missing.token"));
UIStyleResolveContext context = {};
context.theme = &theme;
context.styleSheet = &styleSheet;
context.selector.typeName = "Button";
context.selector.styleName = "Danger";
const auto backgroundResolution =
XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::BackgroundColor, context);
ASSERT_TRUE(backgroundResolution.resolved);
EXPECT_EQ(backgroundResolution.layer, UIStyleLayer::Type);
ASSERT_NE(backgroundResolution.value.TryGetColor(), nullptr);
ExpectColorEq(*backgroundResolution.value.TryGetColor(), Color(0.90f, 0.20f, 0.10f, 1.0f));
const UIResolvedStyle resolvedStyle = XCEngine::UI::Style::ResolveStyle(context);
const auto* borderWidthResolution = resolvedStyle.FindProperty(UIStylePropertyId::BorderWidth);
ASSERT_NE(borderWidthResolution, nullptr);
EXPECT_EQ(borderWidthResolution->layer, UIStyleLayer::Default);
ASSERT_NE(borderWidthResolution->value.TryGetFloat(), nullptr);
EXPECT_FLOAT_EQ(*borderWidthResolution->value.TryGetFloat(), 1.0f);
}
TEST(UI_StyleSystem, StyleResolutionCoercesUniformFloatValuesForPaddingAndRadius) {
UIThemeDefinition themeDefinition = {};
themeDefinition.SetToken("space.compact", UIStyleValue(10.0f));
themeDefinition.SetToken("radius.control", UIStyleValue(6.0f));
const UITheme theme = BuildTheme(themeDefinition);
UIStyleSheet styleSheet = {};
styleSheet.GetOrCreateNamedStyle("Chip")
.SetProperty(UIStylePropertyId::Padding, UIStyleValue::Token("space.compact"));
styleSheet.GetOrCreateNamedStyle("Chip")
.SetProperty(UIStylePropertyId::CornerRadius, UIStyleValue::Token("radius.control"));
UIStyleResolveContext context = {};
context.theme = &theme;
context.styleSheet = &styleSheet;
context.selector.typeName = "Button";
context.selector.styleName = "Chip";
const auto paddingResolution =
XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::Padding, context);
ASSERT_TRUE(paddingResolution.resolved);
ASSERT_NE(paddingResolution.value.TryGetThickness(), nullptr);
EXPECT_TRUE(paddingResolution.value.TryGetThickness()->IsUniform());
EXPECT_FLOAT_EQ(paddingResolution.value.TryGetThickness()->left, 10.0f);
const auto radiusResolution =
XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::CornerRadius, context);
ASSERT_TRUE(radiusResolution.resolved);
ASSERT_NE(radiusResolution.value.TryGetCornerRadius(), nullptr);
EXPECT_TRUE(radiusResolution.value.TryGetCornerRadius()->IsUniform());
EXPECT_FLOAT_EQ(radiusResolution.value.TryGetCornerRadius()->topLeft, 6.0f);
}
TEST(UI_StyleSystem, DocumentThemeCompilerBuildsThemeSheetAndInlineStyles) {
const UIDocumentModel document = MakeThemeDocument();
const auto compiled = CompileDocumentStyle(document);
ASSERT_TRUE(compiled.succeeded);
EXPECT_EQ(compiled.theme.GetName(), "ValidationTheme");
const auto surfaceToken = compiled.theme.ResolveToken("color.surface", UIStyleValueType::Color);
ASSERT_EQ(surfaceToken.status, UITokenResolveStatus::Resolved);
ASSERT_NE(surfaceToken.value.TryGetColor(), nullptr);
ExpectColorEq(*surfaceToken.value.TryGetColor(), Color(0x20 / 255.0f, 0x22 / 255.0f, 0x24 / 255.0f, 1.0f));
UIStyleResolveContext context = {};
context.theme = &compiled.theme;
context.styleSheet = &compiled.styleSheet;
context.selector.typeName = "Card";
context.selector.styleName = "ValidationPanel";
const UIResolvedStyle resolvedStyle = XCEngine::UI::Style::ResolveStyle(context);
const auto* background = resolvedStyle.FindProperty(UIStylePropertyId::BackgroundColor);
ASSERT_NE(background, nullptr);
EXPECT_EQ(background->layer, UIStyleLayer::Named);
ASSERT_NE(background->value.TryGetColor(), nullptr);
ExpectColorEq(*background->value.TryGetColor(), Color(0x20 / 255.0f, 0x22 / 255.0f, 0x24 / 255.0f, 1.0f));
const auto* padding = resolvedStyle.FindProperty(UIStylePropertyId::Padding);
ASSERT_NE(padding, nullptr);
EXPECT_EQ(padding->layer, UIStyleLayer::Named);
ASSERT_NE(padding->value.TryGetThickness(), nullptr);
EXPECT_TRUE(padding->value.TryGetThickness()->IsUniform());
EXPECT_FLOAT_EQ(padding->value.TryGetThickness()->left, 12.0f);
const auto* borderColor = resolvedStyle.FindProperty(UIStylePropertyId::BorderColor);
ASSERT_NE(borderColor, nullptr);
EXPECT_EQ(borderColor->layer, UIStyleLayer::Type);
ASSERT_NE(borderColor->value.TryGetColor(), nullptr);
ExpectColorEq(*borderColor->value.TryGetColor(), Color(0x45 / 255.0f, 0x48 / 255.0f, 0x4C / 255.0f, 1.0f));
const UIDocumentNode buttonNode = MakeNode(
"Button",
{
{ "background", "#2E3135" },
{ "padding", "18" },
{ "radius", "8" },
{ "fontSize", "16" }
});
const UIStyleSet inlineStyle = BuildInlineStyle(buttonNode);
UIStyleResolveContext inlineContext = {};
inlineContext.localStyle = &inlineStyle;
const UIResolvedStyle inlineResolved = XCEngine::UI::Style::ResolveStyle(inlineContext);
const auto* inlinePadding = inlineResolved.FindProperty(UIStylePropertyId::Padding);
ASSERT_NE(inlinePadding, nullptr);
ASSERT_NE(inlinePadding->value.TryGetThickness(), nullptr);
EXPECT_TRUE(inlinePadding->value.TryGetThickness()->IsUniform());
EXPECT_FLOAT_EQ(inlinePadding->value.TryGetThickness()->left, 18.0f);
const auto* inlineRadius = inlineResolved.FindProperty(UIStylePropertyId::CornerRadius);
ASSERT_NE(inlineRadius, nullptr);
ASSERT_NE(inlineRadius->value.TryGetCornerRadius(), nullptr);
EXPECT_TRUE(inlineRadius->value.TryGetCornerRadius()->IsUniform());
EXPECT_FLOAT_EQ(inlineRadius->value.TryGetCornerRadius()->topLeft, 8.0f);
}
} // namespace

View File

@@ -0,0 +1,59 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Text/UITextEditing.h>
#include <string>
namespace {
namespace UIText = XCEngine::UI::Text;
TEST(UITextEditingTest, Utf8CountingAndCaretOffsetsRespectCodepointBoundaries) {
const std::string text = std::string("A") + "\xE4\xBD\xA0" + "B";
EXPECT_EQ(UIText::CountUtf8Codepoints(text), 3u);
EXPECT_EQ(UIText::AdvanceUtf8Offset(text, 0u), 1u);
EXPECT_EQ(UIText::AdvanceUtf8Offset(text, 1u), 4u);
EXPECT_EQ(UIText::AdvanceUtf8Offset(text, 4u), 5u);
EXPECT_EQ(UIText::RetreatUtf8Offset(text, text.size()), 4u);
EXPECT_EQ(UIText::RetreatUtf8Offset(text, 4u), 1u);
EXPECT_EQ(UIText::RetreatUtf8Offset(text, 1u), 0u);
}
TEST(UITextEditingTest, AppendUtf8CodepointEncodesCharactersAndSkipsInvalidSurrogates) {
std::string text = {};
UIText::AppendUtf8Codepoint(text, 'A');
UIText::AppendUtf8Codepoint(text, 0x4F60u);
UIText::AppendUtf8Codepoint(text, 0x1F642u);
UIText::AppendUtf8Codepoint(text, 0xD800u);
EXPECT_EQ(text, std::string("A") + "\xE4\xBD\xA0" + "\xF0\x9F\x99\x82");
EXPECT_EQ(UIText::CountUtf8Codepoints(text), 3u);
}
TEST(UITextEditingTest, SplitLinesAndLineHelpersTrackMultilineRanges) {
const std::string text = "alpha\nbeta\n";
const auto lines = UIText::SplitLines(text);
ASSERT_EQ(lines.size(), 3u);
EXPECT_EQ(lines[0], "alpha");
EXPECT_EQ(lines[1], "beta");
EXPECT_EQ(lines[2], "");
EXPECT_EQ(UIText::CountTextLines(text), 3u);
EXPECT_EQ(UIText::CountUtf8CodepointsInRange(text, 6u, 10u), 4u);
EXPECT_EQ(UIText::FindLineStartOffset(text, 7u), 6u);
EXPECT_EQ(UIText::FindLineEndOffset(text, 7u), 10u);
}
TEST(UITextEditingTest, MoveCaretVerticallyPreservesUtf8ColumnWhenPossible) {
const std::string text = std::string("A") + "\xE4\xBD\xA0" + "Z\nBC\n";
const std::size_t secondColumnCaret = UIText::AdvanceUtf8Offset(text, 1u);
const std::size_t movedDown = UIText::MoveCaretVertically(text, secondColumnCaret, 1);
const std::size_t movedBackUp = UIText::MoveCaretVertically(text, movedDown, -1);
EXPECT_EQ(movedDown, 8u);
EXPECT_EQ(movedBackUp, secondColumnCaret);
}
} // namespace

View File

@@ -0,0 +1,287 @@
#include <gtest/gtest.h>
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/Text/UITextInputController.h>
namespace {
namespace UIText = XCEngine::UI::Text;
using XCEngine::Input::KeyCode;
TEST(UITextInputControllerTest, InsertCharacterTracksUtf8CaretMovement) {
UIText::UITextInputState state = {};
EXPECT_TRUE(UIText::InsertCharacter(state, 'A'));
EXPECT_TRUE(UIText::InsertCharacter(state, 0x4F60u));
EXPECT_EQ(state.value, std::string("A") + "\xE4\xBD\xA0");
EXPECT_EQ(state.caret, state.value.size());
}
TEST(UITextInputControllerTest, BackspaceAndArrowKeysUseUtf8Boundaries) {
UIText::UITextInputState state = {};
state.value = std::string("A") + "\xE4\xBD\xA0" + "B";
state.caret = state.value.size();
const auto moveLeft = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Left),
{},
{});
EXPECT_TRUE(moveLeft.handled);
EXPECT_EQ(state.caret, 4u);
const auto backspace = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Backspace),
{},
{});
EXPECT_TRUE(backspace.handled);
EXPECT_TRUE(backspace.valueChanged);
EXPECT_EQ(state.value, "AB");
EXPECT_EQ(state.caret, 1u);
}
TEST(UITextInputControllerTest, DeleteUsesUtf8BoundariesAndLeavesCaretAtDeletePoint) {
if (static_cast<std::int32_t>(KeyCode::Delete) ==
static_cast<std::int32_t>(KeyCode::Backspace)) {
GTEST_SKIP() << "KeyCode::Delete currently aliases Backspace.";
}
UIText::UITextInputState state = {};
state.value = std::string("A") + "\xE4\xBD\xA0" + "B";
state.caret = 1u;
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Delete),
{},
{});
EXPECT_TRUE(result.handled);
EXPECT_TRUE(result.valueChanged);
EXPECT_FALSE(result.submitRequested);
EXPECT_EQ(state.value, "AB");
EXPECT_EQ(state.caret, 1u);
}
TEST(UITextInputControllerTest, DeleteClampsOversizedCaretAndDoesNotMutateAtDocumentEnd) {
if (static_cast<std::int32_t>(KeyCode::Delete) ==
static_cast<std::int32_t>(KeyCode::Backspace)) {
GTEST_SKIP() << "KeyCode::Delete currently aliases Backspace.";
}
UIText::UITextInputState state = {};
state.value = "AB";
state.caret = 99u;
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Delete),
{},
{});
EXPECT_TRUE(result.handled);
EXPECT_FALSE(result.valueChanged);
EXPECT_FALSE(result.submitRequested);
EXPECT_EQ(state.value, "AB");
EXPECT_EQ(state.caret, state.value.size());
}
TEST(UITextInputControllerTest, SingleLineEnterRequestsSubmitWithoutMutatingValue) {
UIText::UITextInputState state = {};
state.value = "prompt";
state.caret = state.value.size();
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Enter),
{},
{});
EXPECT_TRUE(result.handled);
EXPECT_FALSE(result.valueChanged);
EXPECT_TRUE(result.submitRequested);
EXPECT_EQ(state.value, "prompt");
}
TEST(UITextInputControllerTest, MultilineEnterAndVerticalMovementStayInController) {
UIText::UITextInputState state = {};
state.value = std::string("A") + "\xE4\xBD\xA0" + "Z\nBC";
state.caret = 4u;
const UIText::UITextInputOptions options = { true, 4u };
const auto moveDown = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Down),
{},
options);
EXPECT_TRUE(moveDown.handled);
EXPECT_EQ(state.caret, 8u);
const auto enter = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Enter),
{},
options);
EXPECT_TRUE(enter.handled);
EXPECT_TRUE(enter.valueChanged);
EXPECT_EQ(state.value, std::string("A") + "\xE4\xBD\xA0" + "Z\nBC\n");
}
TEST(UITextInputControllerTest, HomeAndEndRespectSingleLineAndMultilineBounds) {
UIText::UITextInputState singleLine = {};
singleLine.value = "prompt";
singleLine.caret = 2u;
const auto singleHome = UIText::HandleKeyDown(
singleLine,
static_cast<std::int32_t>(KeyCode::Home),
{},
{});
EXPECT_TRUE(singleHome.handled);
EXPECT_EQ(singleLine.caret, 0u);
const auto singleEnd = UIText::HandleKeyDown(
singleLine,
static_cast<std::int32_t>(KeyCode::End),
{},
{});
EXPECT_TRUE(singleEnd.handled);
EXPECT_EQ(singleLine.caret, singleLine.value.size());
UIText::UITextInputState multiline = {};
multiline.value = "root\nleaf\nend";
multiline.caret = 7u;
const UIText::UITextInputOptions options = { true, 4u };
const auto multilineHome = UIText::HandleKeyDown(
multiline,
static_cast<std::int32_t>(KeyCode::Home),
{},
options);
EXPECT_TRUE(multilineHome.handled);
EXPECT_EQ(multiline.caret, 5u);
multiline.caret = 7u;
const auto multilineEnd = UIText::HandleKeyDown(
multiline,
static_cast<std::int32_t>(KeyCode::End),
{},
options);
EXPECT_TRUE(multilineEnd.handled);
EXPECT_EQ(multiline.caret, 9u);
}
TEST(UITextInputControllerTest, ClampCaretAndInsertCharacterRecoverFromOversizedCaret) {
UIText::UITextInputState state = {};
state.value = "go";
state.caret = 42u;
UIText::ClampCaret(state);
EXPECT_EQ(state.caret, state.value.size());
state.caret = 42u;
EXPECT_TRUE(UIText::InsertCharacter(state, '!'));
EXPECT_EQ(state.value, "go!");
EXPECT_EQ(state.caret, state.value.size());
}
TEST(UITextInputControllerTest, MultilineTabAndShiftTabIndentAndOutdentCurrentLine) {
UIText::UITextInputState state = {};
state.value = "root\nnode";
state.caret = 5u;
const UIText::UITextInputOptions options = { true, 4u };
const auto indent = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Tab),
{},
options);
EXPECT_TRUE(indent.handled);
EXPECT_TRUE(indent.valueChanged);
EXPECT_EQ(state.value, "root\n node");
EXPECT_EQ(state.caret, 9u);
XCEngine::UI::UIInputModifiers modifiers = {};
modifiers.shift = true;
const auto outdent = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Tab),
modifiers,
options);
EXPECT_TRUE(outdent.handled);
EXPECT_TRUE(outdent.valueChanged);
EXPECT_EQ(state.value, "root\nnode");
EXPECT_EQ(state.caret, 5u);
}
TEST(UITextInputControllerTest, ShiftTabWithoutLeadingSpacesIsHandledWithoutMutatingText) {
UIText::UITextInputState state = {};
state.value = "root\nnode";
state.caret = 5u;
XCEngine::UI::UIInputModifiers modifiers = {};
modifiers.shift = true;
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Tab),
modifiers,
{ true, 4u });
EXPECT_TRUE(result.handled);
EXPECT_FALSE(result.valueChanged);
EXPECT_FALSE(result.submitRequested);
EXPECT_EQ(state.value, "root\nnode");
EXPECT_EQ(state.caret, 5u);
}
TEST(UITextInputControllerTest, MultilineTabIgnoresSystemModifiers) {
const auto buildState = []() {
UIText::UITextInputState state = {};
state.value = "root\nnode";
state.caret = 5u;
return state;
};
const UIText::UITextInputOptions options = { true, 4u };
XCEngine::UI::UIInputModifiers control = {};
control.control = true;
auto controlState = buildState();
const auto controlResult = UIText::HandleKeyDown(
controlState,
static_cast<std::int32_t>(KeyCode::Tab),
control,
options);
EXPECT_FALSE(controlResult.handled);
EXPECT_FALSE(controlResult.valueChanged);
EXPECT_EQ(controlState.value, "root\nnode");
EXPECT_EQ(controlState.caret, 5u);
XCEngine::UI::UIInputModifiers alt = {};
alt.alt = true;
auto altState = buildState();
const auto altResult = UIText::HandleKeyDown(
altState,
static_cast<std::int32_t>(KeyCode::Tab),
alt,
options);
EXPECT_FALSE(altResult.handled);
EXPECT_FALSE(altResult.valueChanged);
EXPECT_EQ(altState.value, "root\nnode");
EXPECT_EQ(altState.caret, 5u);
XCEngine::UI::UIInputModifiers superModifier = {};
superModifier.super = true;
auto superState = buildState();
const auto superResult = UIText::HandleKeyDown(
superState,
static_cast<std::int32_t>(KeyCode::Tab),
superModifier,
options);
EXPECT_FALSE(superResult.handled);
EXPECT_FALSE(superResult.valueChanged);
EXPECT_EQ(superState.value, "root\nnode");
EXPECT_EQ(superState.caret, 5u);
}
} // namespace

View File

@@ -2,7 +2,6 @@ cmake_minimum_required(VERSION 3.15)
project(XCEngine_EditorUITests)
add_subdirectory(integration/shared)
add_subdirectory(unit)
add_subdirectory(integration)

View File

@@ -1,8 +1,11 @@
add_subdirectory(input)
add_subdirectory(layout)
file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH)
add_subdirectory(shared)
add_subdirectory(workspace_shell_compose)
add_subdirectory(state)
add_custom_target(editor_ui_integration_tests
DEPENDS
editor_ui_input_integration_tests
editor_ui_layout_integration_tests
editor_ui_workspace_shell_compose_validation
editor_ui_panel_session_flow_validation
)

View File

@@ -1,19 +1,56 @@
# Editor UI Integration Validation
This directory contains the manual XCUI validation system for editor-facing scenarios.
This directory contains editor-only XCUI manual validation scenarios.
Structure:
Current status:
- `shared/`: shared host, native renderer, screenshot helper, scenario registry
- `input/`: input-related validation category
- `layout/`: layout and shell-foundation validation category
- Shared Core primitives remain in `tests/UI/Core/integration/`.
- Only editor-only host, shell, widget, and domain-integrated validation should live here.
- The first authored scenario is `workspace_shell_compose/`, focused on shell compose only:
splitters, tab host, panel chrome placeholders, and hot reload.
- The second scenario is `state/panel_session_flow/`, focused on editor command dispatch and panel session state only:
`command dispatch + workspace controller + open / close / show / hide / activate`.
Rules:
Layout:
- 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.
- `shared/`: editor validation scenario registry, Win32 host wrapper, shared theme
- `workspace_shell_compose/`: first manual editor shell compose scenario
- `state/panel_session_flow/`: custom host scenario for editor panel session state flow
Current scenario:
- Scenario id: `editor.shell.workspace_compose`
- Build target: `editor_ui_workspace_shell_compose_validation`
- Executable name: `XCUIEditorWorkspaceShellComposeValidation`
- Validation scope: split/tab/panel shell compose only, no business panels
Additional scenario:
- Scenario id: `editor.state.panel_session_flow`
- Build target: `editor_ui_panel_session_flow_validation`
- Executable name: `XCUIEditorPanelSessionFlowValidation`
- Validation scope: editor command dispatch and panel session state only, no business panels
Run:
```bash
cmake --build build --config Debug --target editor_ui_workspace_shell_compose_validation
```
Then launch `XCUIEditorWorkspaceShellComposeValidation.exe` from the build output, or run it from your IDE by target name.
Controls:
- Drag authored splitters to verify live resize and min clamps.
- Click `Document A/B/C` to verify only the selected tab placeholder is visible.
- Press `F12` to write screenshots into `workspace_shell_compose/captures/`.
- Authored `.xcui` and `.xctheme` changes hot reload while the host is running.
Panel session flow controls:
- Click `Hide Active / Show Doc A / Close Doc B / Open Doc B / Activate Details / Reset`.
- Check `Last command` shows `Changed / NoOp / Rejected` consistently with the current state.
- Press `F12` to write screenshots into `state/panel_session_flow/captures/`.
Build:

View File

@@ -1,12 +0,0 @@
add_subdirectory(keyboard_focus)
add_subdirectory(pointer_states)
add_subdirectory(scroll_view)
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_scroll_view_validation
editor_ui_input_shortcut_scope_validation
)

View File

@@ -1,9 +0,0 @@
# Editor Input Integration
这个分类只放 editor 输入相关的手工验证场景。
规则:
- 一个场景目录对应一个独立 exe
- 共享宿主层只放在 `integration/shared/`
- 不允许把多个无关检查点塞进同一个 exe

View File

@@ -1,35 +0,0 @@
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)

View File

@@ -1,18 +0,0 @@
# 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` 清空

View File

@@ -1,35 +0,0 @@
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)

View File

@@ -1,17 +0,0 @@
# 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 转到新的目标

View File

@@ -1,35 +0,0 @@
set(EDITOR_UI_INPUT_SCROLL_VIEW_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
)
add_executable(editor_ui_input_scroll_view_validation WIN32
main.cpp
${EDITOR_UI_INPUT_SCROLL_VIEW_RESOURCES}
)
target_include_directories(editor_ui_input_scroll_view_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_input_scroll_view_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(editor_ui_input_scroll_view_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_input_scroll_view_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_input_scroll_view_validation PRIVATE
editor_ui_integration_host
)
set_target_properties(editor_ui_input_scroll_view_validation PROPERTIES
OUTPUT_NAME "XCUIEditorInputScrollViewValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -1,35 +0,0 @@
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)

View File

@@ -1,10 +0,0 @@
add_subdirectory(splitter_resize)
add_subdirectory(tab_strip_selection)
add_subdirectory(workspace_compose)
add_custom_target(editor_ui_layout_integration_tests
DEPENDS
editor_ui_layout_splitter_resize_validation
editor_ui_layout_tab_strip_selection_validation
editor_ui_layout_workspace_compose_validation
)

View File

@@ -1,35 +0,0 @@
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)

View File

@@ -1,8 +0,0 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
hInstance,
nCmdShow,
"editor.layout.splitter_resize");
}

View File

@@ -1,35 +0,0 @@
set(EDITOR_UI_LAYOUT_TAB_STRIP_SELECTION_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
)
add_executable(editor_ui_layout_tab_strip_selection_validation WIN32
main.cpp
${EDITOR_UI_LAYOUT_TAB_STRIP_SELECTION_RESOURCES}
)
target_include_directories(editor_ui_layout_tab_strip_selection_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_layout_tab_strip_selection_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(editor_ui_layout_tab_strip_selection_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_layout_tab_strip_selection_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_layout_tab_strip_selection_validation PRIVATE
editor_ui_integration_host
)
set_target_properties(editor_ui_layout_tab_strip_selection_validation PROPERTIES
OUTPUT_NAME "XCUIEditorLayoutTabStripSelectionValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -1,8 +0,0 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
hInstance,
nCmdShow,
"editor.layout.tab_strip_selection");
}

View File

@@ -1,35 +0,0 @@
set(EDITOR_UI_LAYOUT_WORKSPACE_COMPOSE_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
)
add_executable(editor_ui_layout_workspace_compose_validation WIN32
main.cpp
${EDITOR_UI_LAYOUT_WORKSPACE_COMPOSE_RESOURCES}
)
target_include_directories(editor_ui_layout_workspace_compose_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_layout_workspace_compose_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(editor_ui_layout_workspace_compose_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_layout_workspace_compose_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_layout_workspace_compose_validation PRIVATE
editor_ui_integration_host
)
set_target_properties(editor_ui_layout_workspace_compose_validation PROPERTIES
OUTPUT_NAME "XCUIEditorLayoutWorkspaceComposeValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -1,8 +0,0 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
hInstance,
nCmdShow,
"editor.layout.workspace_compose");
}

View File

@@ -7,7 +7,6 @@ add_library(editor_ui_validation_registry STATIC
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
@@ -21,11 +20,6 @@ if(MSVC)
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
)
@@ -33,8 +27,8 @@ add_library(editor_ui_integration_host STATIC
target_include_directories(editor_ui_integration_host
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/new_editor/include
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/new_editor/include
)
target_compile_definitions(editor_ui_integration_host
@@ -54,4 +48,5 @@ target_link_libraries(editor_ui_integration_host
PUBLIC
editor_ui_validation_registry
XCNewEditorHost
XCEngine
)

View File

@@ -11,14 +11,11 @@
#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::Input::KeyCode;
using ::XCEngine::UI::UIColor;
using ::XCEngine::UI::UIDrawData;
using ::XCEngine::UI::UIDrawList;
@@ -28,10 +25,9 @@ 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 const wchar_t* kWindowTitle = L"XCUI Editor 验证";
constexpr auto kReloadPollInterval = std::chrono::milliseconds(150);
constexpr UIColor kOverlayBgColor(0.10f, 0.10f, 0.10f, 0.95f);
@@ -45,14 +41,6 @@ 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;
@@ -90,14 +78,6 @@ 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);
@@ -242,14 +222,14 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) {
return false;
}
m_startTime = std::chrono::steady_clock::now();
m_lastFrameTime = m_startTime;
m_lastFrameTime = std::chrono::steady_clock::now();
const EditorValidationScenario* initialScenario = m_requestedScenarioId.empty()
? &GetDefaultEditorValidationScenario()
: FindEditorValidationScenario(m_requestedScenarioId);
if (initialScenario == nullptr) {
initialScenario = &GetDefaultEditorValidationScenario();
}
m_autoScreenshot.Initialize(initialScenario->captureRootPath);
LoadStructuredScreen("startup");
return true;
@@ -315,12 +295,12 @@ void Application::RenderFrame() {
m_runtimeStatus = m_activeScenario != nullptr
? m_activeScenario->displayName
: "Editor UI Validation";
: "Editor UI 验证";
m_runtimeError = frame.errorMessage;
}
if (drawData.Empty()) {
m_runtimeStatus = "Editor UI Validation | Load Error";
m_runtimeStatus = "Editor UI 验证 | 加载失败";
if (m_runtimeError.empty() && !m_screenPlayer.IsLoaded()) {
m_runtimeError = m_screenPlayer.GetLastError();
}
@@ -352,7 +332,7 @@ void Application::QueuePointerEvent(UIInputEventType type, UIPointerButton butto
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));
event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<std::size_t>(wParam));
m_pendingInputEvents.push_back(event);
}
@@ -383,7 +363,7 @@ void Application::QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM
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));
event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<std::size_t>(wParam));
m_pendingInputEvents.push_back(event);
}
@@ -412,6 +392,7 @@ void Application::QueueWindowFocusEvent(UIInputEventType type) {
bool Application::LoadStructuredScreen(const char* triggerReason) {
(void)triggerReason;
std::string scenarioLoadWarning = {};
const EditorValidationScenario* scenario = m_requestedScenarioId.empty()
? &GetDefaultEditorValidationScenario()
@@ -429,7 +410,7 @@ bool Application::LoadStructuredScreen(const char* triggerReason) {
const bool loaded = m_screenPlayer.Load(m_screenAsset);
m_useStructuredScreen = loaded;
m_runtimeStatus = loaded ? scenario->displayName : "Editor UI Validation | Load Error";
m_runtimeStatus = loaded ? scenario->displayName : "Editor UI 验证 | 加载失败";
m_runtimeError = loaded
? scenarioLoadWarning
: (scenarioLoadWarning.empty()
@@ -518,19 +499,19 @@ bool Application::DetectTrackedFileChange() const {
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;
const float panelWidth = authoredMode ? 470.0f : 390.0f;
std::vector<std::string> detailLines = {};
detailLines.push_back(
authoredMode
? "Hot reload watches authored UI resources."
: "Authored validation scene failed to load.");
? "热重载正在监听当前 Editor 集成测试资源。"
: "当前 Editor 验证场景加载失败。");
if (m_activeScenario != nullptr) {
detailLines.push_back("Scenario: " + m_activeScenario->id);
detailLines.push_back("当前场景: " + m_activeScenario->id);
}
detailLines.push_back("验证范围: Splitter / TabStrip / Panel Frame / 占位内容");
if (authoredMode) {
const auto& inputDebug = m_documentHost.GetInputDebugSnapshot();
const auto& scrollDebug = m_documentHost.GetScrollDebugSnapshot();
detailLines.push_back(
"Hover | Focus: " +
ExtractStateKeyTail(inputDebug.hoveredStateKey) +
@@ -541,32 +522,6 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float
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" ||
@@ -576,59 +531,26 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float
? 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: " +
"Result: " +
(inputDebug.lastResult.empty() ? std::string("n/a") : inputDebug.lastResult));
}
detailLines.push_back(
"Scroll target | Primary: " +
ExtractStateKeyTail(scrollDebug.lastTargetStateKey) +
" | " +
ExtractStateKeyTail(scrollDebug.primaryTargetStateKey));
detailLines.push_back(
"Scroll offset B/A: " +
FormatFloat(scrollDebug.lastOffsetBefore) +
" -> " +
FormatFloat(scrollDebug.lastOffsetAfter) +
" | overflow " +
FormatFloat(scrollDebug.lastOverflow));
detailLines.push_back(
"Scroll H/T: " +
std::to_string(scrollDebug.handledWheelEventCount) +
"/" +
std::to_string(scrollDebug.totalWheelEventCount) +
" | " +
(scrollDebug.lastResult.empty() ? std::string("n/a") : scrollDebug.lastResult));
}
if (m_autoScreenshot.HasPendingCapture()) {
detailLines.push_back("Shot pending...");
detailLines.push_back("截图排队中...");
} else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) {
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureSummary(), 78u));
} else {
detailLines.push_back("Screenshots: F12 -> current scenario captures/");
detailLines.push_back("截图: F12 -> 当前场景 captures/");
}
if (!m_runtimeError.empty()) {
@@ -636,13 +558,13 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float
} 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.");
detailLines.push_back("当前宿主不会回退到 sandbox 画面。");
}
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");
UIDrawList& overlay = drawData.EmplaceDrawList("Editor UI 验证浮层");
overlay.AddFilledRect(panelRect, kOverlayBgColor, 10.0f);
overlay.AddRectOutline(panelRect, kOverlayBorderColor, 1.0f, 10.0f);
overlay.AddFilledRect(
@@ -651,7 +573,7 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float
4.0f);
overlay.AddText(
UIPoint(panelRect.x + 28.0f, panelRect.y + 10.0f),
m_runtimeStatus.empty() ? "Editor UI Validation" : m_runtimeStatus,
m_runtimeStatus.empty() ? "Editor UI 验证" : m_runtimeStatus,
kOverlayTextPrimary,
14.0f);
@@ -669,10 +591,6 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float
}
}
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);

View File

@@ -5,6 +5,7 @@
#endif
#include "EditorValidationScenario.h"
#include <XCNewEditor/Host/AutoScreenshot.h>
#include <XCNewEditor/Host/InputModifierTracker.h>
#include <XCNewEditor/Host/NativeRenderer.h>
@@ -53,7 +54,6 @@ private:
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;
@@ -66,7 +66,6 @@ private:
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;

View File

@@ -17,6 +17,7 @@ fs::path RepoRootPath() {
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
root = root.substr(1u, root.size() - 2u);
}
return fs::path(root).lexically_normal();
}
@@ -24,72 +25,19 @@ fs::path RepoRelative(const char* relativePath) {
return (RepoRootPath() / relativePath).lexically_normal();
}
const std::array<EditorValidationScenario, 7>& GetEditorValidationScenarios() {
static const std::array<EditorValidationScenario, 7> scenarios = { {
const std::array<EditorValidationScenario, 1>& GetEditorValidationScenarios() {
static const std::array<EditorValidationScenario, 1> scenarios = { {
{
"editor.input.keyboard_focus",
"editor.shell.workspace_compose",
UIValidationDomain::Editor,
"input",
"Editor Input | Keyboard Focus",
RepoRelative("tests/UI/Editor/integration/input/keyboard_focus/View.xcui"),
"shell",
"Editor 壳层 | 工作区组合",
RepoRelative("tests/UI/Editor/integration/workspace_shell_compose/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.scroll_view",
UIValidationDomain::Editor,
"input",
"Editor Input | Scroll View",
RepoRelative("tests/UI/Editor/integration/input/scroll_view/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/input/scroll_view/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")
},
{
"editor.layout.tab_strip_selection",
UIValidationDomain::Editor,
"layout",
"Editor Layout | TabStrip Selection",
RepoRelative("tests/UI/Editor/integration/layout/tab_strip_selection/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/layout/tab_strip_selection/captures")
},
{
"editor.layout.workspace_compose",
UIValidationDomain::Editor,
"layout",
"Editor Layout | Workspace Compose",
RepoRelative("tests/UI/Editor/integration/layout/workspace_compose/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/layout/workspace_compose/captures")
RepoRelative("tests/UI/Editor/integration/workspace_shell_compose/captures")
}
} };
return scenarios;
}

View File

@@ -7,8 +7,7 @@
namespace XCEngine::Tests::EditorUI {
enum class UIValidationDomain : unsigned char {
Editor = 0,
Runtime
Editor = 0
};
struct EditorValidationScenario {

View File

@@ -1,30 +1,29 @@
<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" />
<Color name="color.bg.workspace" value="#1A1A1A" />
<Color name="color.bg.panel" value="#262626" />
<Color name="color.bg.selection" value="#3A3A3A" />
<Color name="color.text.primary" value="#F2F2F2" />
<Color name="color.text.muted" value="#B4B4B4" />
<Spacing name="space.shell" value="18" />
<Spacing name="space.panel" value="12" />
<Radius name="radius.panel" value="10" />
<Radius name="radius.control" value="8" />
</Tokens>
<Widgets>
<Widget type="View" style="EditorWorkspace">
<Widget type="View" style="EditorValidationWorkspace">
<Property name="background" value="color.bg.workspace" />
<Property name="padding" value="space.shell" />
</Widget>
<Widget type="Card" style="EditorPanel">
<Widget type="Card" style="EditorShellPanel">
<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">
<Widget type="Button" style="EditorShellChip">
<Property name="background" value="color.bg.selection" />
<Property name="radius" value="radius.control" />
</Widget>

View File

@@ -0,0 +1 @@
add_subdirectory(panel_session_flow)

View File

@@ -0,0 +1,29 @@
add_executable(editor_ui_panel_session_flow_validation WIN32
main.cpp
)
target_include_directories(editor_ui_panel_session_flow_validation PRIVATE
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/new_editor/include
)
target_compile_definitions(editor_ui_panel_session_flow_validation PRIVATE
UNICODE
_UNICODE
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(editor_ui_panel_session_flow_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_panel_session_flow_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_panel_session_flow_validation PRIVATE
XCNewEditorLib
XCNewEditorHost
)
set_target_properties(editor_ui_panel_session_flow_validation PROPERTIES
OUTPUT_NAME "XCUIEditorPanelSessionFlowValidation"
)

View File

@@ -0,0 +1,648 @@
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCNewEditor/Editor/UIEditorWorkspaceController.h>
#include <XCNewEditor/Host/AutoScreenshot.h>
#include <XCNewEditor/Host/NativeRenderer.h>
#include <XCEngine/UI/DrawData.h>
#include <windows.h>
#include <windowsx.h>
#include <algorithm>
#include <filesystem>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
#endif
namespace {
using XCEngine::NewEditor::BuildDefaultUIEditorWorkspaceController;
using XCEngine::NewEditor::BuildUIEditorWorkspacePanel;
using XCEngine::NewEditor::BuildUIEditorWorkspaceSplit;
using XCEngine::NewEditor::BuildUIEditorWorkspaceTabStack;
using XCEngine::NewEditor::CollectUIEditorWorkspaceVisiblePanels;
using XCEngine::NewEditor::FindUIEditorPanelSessionState;
using XCEngine::NewEditor::GetUIEditorWorkspaceCommandKindName;
using XCEngine::NewEditor::GetUIEditorWorkspaceCommandStatusName;
using XCEngine::NewEditor::UIEditorPanelRegistry;
using XCEngine::NewEditor::UIEditorWorkspaceCommand;
using XCEngine::NewEditor::UIEditorWorkspaceCommandKind;
using XCEngine::NewEditor::UIEditorWorkspaceCommandResult;
using XCEngine::NewEditor::UIEditorWorkspaceController;
using XCEngine::NewEditor::UIEditorWorkspaceModel;
using XCEngine::NewEditor::UIEditorWorkspaceSession;
using XCEngine::NewEditor::UIEditorWorkspaceSplitAxis;
using XCEngine::UI::UIColor;
using XCEngine::UI::UIDrawData;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::XCUI::Host::AutoScreenshotController;
using XCEngine::XCUI::Host::NativeRenderer;
constexpr const wchar_t* kWindowClassName = L"XCUIEditorPanelSessionFlowValidation";
constexpr const wchar_t* kWindowTitle = L"XCUI Editor | Panel Session 状态流";
constexpr UIColor kWindowBg(0.15f, 0.15f, 0.15f, 1.0f);
constexpr UIColor kCardBg(0.20f, 0.20f, 0.20f, 1.0f);
constexpr UIColor kCardBorder(0.29f, 0.29f, 0.29f, 1.0f);
constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f);
constexpr UIColor kTextMuted(0.72f, 0.72f, 0.72f, 1.0f);
constexpr UIColor kAccent(0.33f, 0.55f, 0.84f, 1.0f);
constexpr UIColor kSuccess(0.43f, 0.71f, 0.47f, 1.0f);
constexpr UIColor kWarning(0.78f, 0.60f, 0.30f, 1.0f);
constexpr UIColor kDanger(0.78f, 0.34f, 0.34f, 1.0f);
constexpr UIColor kButtonEnabled(0.32f, 0.32f, 0.32f, 1.0f);
constexpr UIColor kButtonDisabled(0.24f, 0.24f, 0.24f, 1.0f);
constexpr UIColor kButtonBorder(0.44f, 0.44f, 0.44f, 1.0f);
enum class ActionId : unsigned char {
HideActive = 0,
ShowDocA,
CloseDocB,
OpenDocB,
ActivateDetails,
Reset
};
struct ButtonState {
ActionId action = ActionId::HideActive;
std::string label = {};
UIRect rect = {};
bool enabled = false;
};
std::filesystem::path ResolveRepoRootPath() {
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();
}
UIEditorPanelRegistry BuildPanelRegistry() {
UIEditorPanelRegistry registry = {};
registry.panels = {
{ "doc-a", "Document A", {}, true, true, true },
{ "doc-b", "Document B", {}, true, true, true },
{ "details", "Details", {}, true, true, true }
};
return registry;
}
UIEditorWorkspaceModel BuildWorkspace() {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceSplit(
"root-split",
UIEditorWorkspaceSplitAxis::Horizontal,
0.66f,
BuildUIEditorWorkspaceTabStack(
"document-tabs",
{
BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true),
BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true)
},
0u),
BuildUIEditorWorkspacePanel("details-node", "details", "Details", true));
workspace.activePanelId = "doc-a";
return workspace;
}
bool ContainsPoint(const UIRect& rect, float x, float y) {
return x >= rect.x &&
x <= rect.x + rect.width &&
y >= rect.y &&
y <= rect.y + rect.height;
}
std::string JoinVisiblePanelIds(
const UIEditorWorkspaceModel& workspace,
const UIEditorWorkspaceSession& session) {
const auto panels = CollectUIEditorWorkspaceVisiblePanels(workspace, session);
if (panels.empty()) {
return "(none)";
}
std::ostringstream stream;
for (std::size_t index = 0; index < panels.size(); ++index) {
if (index > 0u) {
stream << ", ";
}
stream << panels[index].panelId;
}
return stream.str();
}
std::string DescribePanelState(
const UIEditorWorkspaceSession& session,
std::string_view panelId,
std::string_view displayName) {
const auto* state = FindUIEditorPanelSessionState(session, panelId);
if (state == nullptr) {
return std::string(displayName) + ": missing";
}
std::string visibility = {};
if (!state->open) {
visibility = "closed";
} else if (!state->visible) {
visibility = "hidden";
} else {
visibility = "visible";
}
return std::string(displayName) + ": " + visibility;
}
UIColor ResolvePanelStateColor(
const UIEditorWorkspaceSession& session,
std::string_view panelId) {
const auto* state = FindUIEditorPanelSessionState(session, panelId);
if (state == nullptr) {
return kDanger;
}
if (!state->open) {
return kDanger;
}
if (!state->visible) {
return kWarning;
}
return kSuccess;
}
void DrawCard(
UIDrawList& drawList,
const UIRect& rect,
std::string_view title,
std::string_view subtitle = {}) {
drawList.AddFilledRect(rect, kCardBg, 12.0f);
drawList.AddRectOutline(rect, kCardBorder, 1.0f, 12.0f);
drawList.AddText(UIPoint(rect.x + 18.0f, rect.y + 16.0f), std::string(title), kTextPrimary, 17.0f);
if (!subtitle.empty()) {
drawList.AddText(UIPoint(rect.x + 18.0f, rect.y + 42.0f), std::string(subtitle), kTextMuted, 12.0f);
}
}
class ScenarioApp {
public:
int 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);
}
private:
static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
if (message == WM_NCCREATE) {
const auto* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
auto* app = reinterpret_cast<ScenarioApp*>(createStruct->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(app));
return TRUE;
}
auto* app = reinterpret_cast<ScenarioApp*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
switch (message) {
case WM_SIZE:
if (app != nullptr && wParam != SIZE_MINIMIZED) {
app->OnResize(static_cast<UINT>(LOWORD(lParam)), static_cast<UINT>(HIWORD(lParam)));
}
return 0;
case WM_PAINT:
if (app != nullptr) {
PAINTSTRUCT paintStruct = {};
BeginPaint(hwnd, &paintStruct);
app->RenderFrame();
EndPaint(hwnd, &paintStruct);
return 0;
}
break;
case WM_LBUTTONUP:
if (app != nullptr) {
app->HandleClick(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)));
return 0;
}
break;
case WM_KEYDOWN:
case WM_SYSKEYDOWN:
if (app != nullptr) {
if (wParam == VK_F12) {
app->m_autoScreenshot.RequestCapture("manual_f12");
} else {
app->HandleShortcut(static_cast<UINT>(wParam));
}
return 0;
}
break;
case WM_ERASEBKGND:
return 1;
case WM_DESTROY:
if (app != nullptr) {
app->m_hwnd = nullptr;
}
PostQuitMessage(0);
return 0;
default:
break;
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
bool Initialize(HINSTANCE hInstance, int nCmdShow) {
m_hInstance = hInstance;
ResetScenario();
WNDCLASSEXW windowClass = {};
windowClass.cbSize = sizeof(windowClass);
windowClass.style = CS_HREDRAW | CS_VREDRAW;
windowClass.lpfnWndProc = &ScenarioApp::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,
1320,
860,
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_autoScreenshot.Initialize(
(ResolveRepoRootPath() / "tests/UI/Editor/integration/state/panel_session_flow/captures")
.lexically_normal());
return true;
}
void Shutdown() {
m_autoScreenshot.Shutdown();
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 ResetScenario() {
m_controller =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
m_hasLastCommandResult = false;
m_lastActionSummary = "等待操作:先点 Hide Active确认命令结果为 Changed并检查 active/visible 状态联动。";
}
void OnResize(UINT width, UINT height) {
if (width == 0 || height == 0) {
return;
}
m_renderer.Resize(width, height);
}
void 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));
UIDrawData drawData = {};
BuildDrawData(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 HandleClick(float x, float y) {
for (const ButtonState& button : m_buttons) {
if (button.enabled && ContainsPoint(button.rect, x, y)) {
DispatchAction(button.action);
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
}
}
void HandleShortcut(UINT keyCode) {
switch (keyCode) {
case '1':
DispatchAction(ActionId::HideActive);
break;
case '2':
DispatchAction(ActionId::ShowDocA);
break;
case '3':
DispatchAction(ActionId::CloseDocB);
break;
case '4':
DispatchAction(ActionId::OpenDocB);
break;
case '5':
DispatchAction(ActionId::ActivateDetails);
break;
case 'R':
DispatchAction(ActionId::Reset);
break;
default:
return;
}
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void DispatchAction(ActionId action) {
std::string label = {};
UIEditorWorkspaceCommand command = {};
switch (action) {
case ActionId::HideActive:
label = "Hide Active";
command.kind = UIEditorWorkspaceCommandKind::HidePanel;
command.panelId = m_controller.GetWorkspace().activePanelId;
break;
case ActionId::ShowDocA:
label = "Show Doc A";
command.kind = UIEditorWorkspaceCommandKind::ShowPanel;
command.panelId = "doc-a";
break;
case ActionId::CloseDocB:
label = "Close Doc B";
command.kind = UIEditorWorkspaceCommandKind::ClosePanel;
command.panelId = "doc-b";
break;
case ActionId::OpenDocB:
label = "Open Doc B";
command.kind = UIEditorWorkspaceCommandKind::OpenPanel;
command.panelId = "doc-b";
break;
case ActionId::ActivateDetails:
label = "Activate Details";
command.kind = UIEditorWorkspaceCommandKind::ActivatePanel;
command.panelId = "details";
break;
case ActionId::Reset:
label = "Reset";
command.kind = UIEditorWorkspaceCommandKind::ResetWorkspace;
break;
}
m_lastCommandResult = m_controller.Dispatch(command);
m_hasLastCommandResult = true;
m_lastActionSummary =
label + " -> " +
std::string(GetUIEditorWorkspaceCommandStatusName(m_lastCommandResult.status));
}
bool IsButtonEnabled(ActionId action) const {
const UIEditorWorkspaceModel& workspace = m_controller.GetWorkspace();
const UIEditorWorkspaceSession& session = m_controller.GetSession();
switch (action) {
case ActionId::HideActive: {
if (workspace.activePanelId.empty()) {
return false;
}
const auto* state = FindUIEditorPanelSessionState(session, workspace.activePanelId);
return state != nullptr && state->open && state->visible;
}
case ActionId::ShowDocA: {
const auto* state = FindUIEditorPanelSessionState(session, "doc-a");
return state != nullptr && state->open && !state->visible;
}
case ActionId::CloseDocB: {
const auto* state = FindUIEditorPanelSessionState(session, "doc-b");
return state != nullptr && state->open;
}
case ActionId::OpenDocB: {
const auto* state = FindUIEditorPanelSessionState(session, "doc-b");
return state != nullptr && !state->open;
}
case ActionId::ActivateDetails: {
const auto* state = FindUIEditorPanelSessionState(session, "details");
return state != nullptr &&
state->open &&
state->visible &&
workspace.activePanelId != "details";
}
case ActionId::Reset:
return true;
}
return false;
}
void BuildDrawData(UIDrawData& drawData, float width, float height) {
const UIEditorWorkspaceModel& workspace = m_controller.GetWorkspace();
const UIEditorWorkspaceSession& session = m_controller.GetSession();
const auto validation = m_controller.ValidateState();
UIDrawList& drawList = drawData.EmplaceDrawList("Editor Panel Session Flow");
drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg);
const float margin = 20.0f;
const UIRect headerRect(margin, margin, width - margin * 2.0f, 170.0f);
const UIRect actionRect(margin, headerRect.y + headerRect.height + 16.0f, 320.0f, height - 246.0f);
const UIRect stateRect(actionRect.x + actionRect.width + 16.0f, actionRect.y, width - actionRect.width - margin * 2.0f - 16.0f, height - 246.0f);
const UIRect footerRect(margin, height - 96.0f, width - margin * 2.0f, 76.0f);
DrawCard(drawList, headerRect, "测试功能Editor Command Dispatch + Workspace Controller", "只验证命令分发、状态变更结果和 panel session 联动;不验证业务面板。");
drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 70.0f), "1. 点 `1 Hide Active`Result 应是 Changedactive 从 doc-a 切到 doc-bselected tab 也同步到 B。", kTextPrimary, 13.0f);
drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 92.0f), "2. 再点 `1 Hide Active`,如果当前 panel 已 hiddenResult 应变成 NoOp而不是乱改状态。", kTextPrimary, 13.0f);
drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 114.0f), "3. 点 `3 Close Doc B` 后再点 `5 Activate Details` / `4 Open Doc B`,检查 Changed / Rejected / NoOp 是否符合提示。", kTextPrimary, 13.0f);
drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 136.0f), "4. `R Reset` 必须把状态拉回基线;按 `F12` 保存当前窗口截图。", kTextPrimary, 13.0f);
DrawCard(drawList, actionRect, "操作区", "每个按钮都会先生成 command再交给 Workspace Controller 分发。");
DrawCard(drawList, stateRect, "状态摘要", "重点检查 active panel、visible panels、selected tab index 和每个 panel session 状态。");
DrawCard(drawList, footerRect, "最近结果", "这块显示 last command、status、message 与当前 validation。");
m_buttons.clear();
const std::vector<std::pair<ActionId, std::string>> buttonDefs = {
{ ActionId::HideActive, "1 Hide Active" },
{ ActionId::ShowDocA, "2 Show Doc A" },
{ ActionId::CloseDocB, "3 Close Doc B" },
{ ActionId::OpenDocB, "4 Open Doc B" },
{ ActionId::ActivateDetails, "5 Activate Details" },
{ ActionId::Reset, "R Reset" }
};
float buttonY = actionRect.y + 72.0f;
for (const auto& [action, label] : buttonDefs) {
ButtonState button = {};
button.action = action;
button.label = label;
button.rect = UIRect(actionRect.x + 18.0f, buttonY, actionRect.width - 36.0f, 46.0f);
button.enabled = IsButtonEnabled(action);
m_buttons.push_back(button);
drawList.AddFilledRect(
button.rect,
button.enabled ? kButtonEnabled : kButtonDisabled,
8.0f);
drawList.AddRectOutline(button.rect, kButtonBorder, 1.0f, 8.0f);
drawList.AddText(
UIPoint(button.rect.x + 14.0f, button.rect.y + 13.0f),
button.label,
button.enabled ? kTextPrimary : kTextMuted,
13.0f);
buttonY += 58.0f;
}
const float leftX = stateRect.x + 18.0f;
drawList.AddText(UIPoint(leftX, stateRect.y + 70.0f), "Current active panel:", kTextMuted, 12.0f);
drawList.AddText(UIPoint(leftX, stateRect.y + 90.0f), workspace.activePanelId.empty() ? "(none)" : workspace.activePanelId, kAccent, 15.0f);
drawList.AddText(UIPoint(leftX, stateRect.y + 124.0f), "Visible panels:", kTextMuted, 12.0f);
drawList.AddText(UIPoint(leftX, stateRect.y + 144.0f), JoinVisiblePanelIds(workspace, session), kTextPrimary, 14.0f);
drawList.AddText(UIPoint(leftX, stateRect.y + 178.0f), "Selected tab index:", kTextMuted, 12.0f);
drawList.AddText(
UIPoint(leftX, stateRect.y + 198.0f),
std::to_string(workspace.root.children.front().selectedTabIndex),
kTextPrimary,
14.0f);
const float pillX = leftX;
const float pillY = stateRect.y + 244.0f;
const std::vector<std::pair<std::string, std::string>> panelDefs = {
{ "doc-a", "Document A" },
{ "doc-b", "Document B" },
{ "details", "Details" }
};
float rowY = pillY;
for (const auto& [panelId, label] : panelDefs) {
const UIRect rowRect(leftX, rowY, stateRect.width - 36.0f, 54.0f);
drawList.AddFilledRect(rowRect, UIColor(0.17f, 0.17f, 0.17f, 1.0f), 8.0f);
drawList.AddRectOutline(rowRect, kCardBorder, 1.0f, 8.0f);
drawList.AddFilledRect(
UIRect(rowRect.x + 12.0f, rowRect.y + 15.0f, 10.0f, 10.0f),
ResolvePanelStateColor(session, panelId),
5.0f);
drawList.AddText(
UIPoint(rowRect.x + 32.0f, rowRect.y + 11.0f),
DescribePanelState(session, panelId, label),
kTextPrimary,
14.0f);
const bool active = workspace.activePanelId == panelId;
drawList.AddText(
UIPoint(rowRect.x + 32.0f, rowRect.y + 31.0f),
active ? "active = true" : "active = false",
active ? kAccent : kTextMuted,
12.0f);
rowY += 64.0f;
}
drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 28.0f), m_lastActionSummary, kTextPrimary, 13.0f);
if (m_hasLastCommandResult) {
drawList.AddText(
UIPoint(footerRect.x + 18.0f, footerRect.y + 48.0f),
"Last command: " +
std::string(GetUIEditorWorkspaceCommandKindName(m_lastCommandResult.kind)) +
" | Status: " +
std::string(GetUIEditorWorkspaceCommandStatusName(m_lastCommandResult.status)) +
" | " +
m_lastCommandResult.message,
m_lastCommandResult.status == XCEngine::NewEditor::UIEditorWorkspaceCommandStatus::Rejected
? kDanger
: (m_lastCommandResult.status == XCEngine::NewEditor::UIEditorWorkspaceCommandStatus::NoOp
? kWarning
: kSuccess),
12.0f);
}
drawList.AddText(
UIPoint(footerRect.x + 18.0f, footerRect.y + 66.0f),
validation.IsValid() ? "Validation: OK" : "Validation: " + validation.message,
validation.IsValid() ? kSuccess : kDanger,
12.0f);
const std::string captureSummary =
m_autoScreenshot.HasPendingCapture()
? "截图排队中..."
: (m_autoScreenshot.GetLastCaptureSummary().empty()
? std::string("F12 -> tests/UI/Editor/integration/state/panel_session_flow/captures/")
: m_autoScreenshot.GetLastCaptureSummary());
drawList.AddText(UIPoint(footerRect.x + 530.0f, footerRect.y + 66.0f), captureSummary, kTextMuted, 12.0f);
}
HWND m_hwnd = nullptr;
HINSTANCE m_hInstance = nullptr;
ATOM m_windowClassAtom = 0;
NativeRenderer m_renderer = {};
AutoScreenshotController m_autoScreenshot = {};
UIEditorWorkspaceController m_controller = {};
std::vector<ButtonState> m_buttons = {};
UIEditorWorkspaceCommandResult m_lastCommandResult = {};
bool m_hasLastCommandResult = false;
std::string m_lastActionSummary = {};
};
} // namespace
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
ScenarioApp app;
return app.Run(hInstance, nCmdShow);
}

View File

@@ -0,0 +1,35 @@
set(EDITOR_UI_WORKSPACE_SHELL_COMPOSE_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
)
add_executable(editor_ui_workspace_shell_compose_validation WIN32
main.cpp
${EDITOR_UI_WORKSPACE_SHELL_COMPOSE_RESOURCES}
)
target_include_directories(editor_ui_workspace_shell_compose_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_workspace_shell_compose_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(editor_ui_workspace_shell_compose_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_workspace_shell_compose_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_workspace_shell_compose_validation PRIVATE
editor_ui_integration_host
)
set_target_properties(editor_ui_workspace_shell_compose_validation PROPERTIES
OUTPUT_NAME "XCUIEditorWorkspaceShellComposeValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,94 @@
<View
name="EditorWorkspaceShellComposeValidation"
theme="../shared/themes/editor_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12">
<Card
title="测试内容Editor Shell 基础壳层组合"
subtitle="只验证 Splitter / TabStrip / Panel Frame / 占位内容;不验证业务面板,不验证 new_editor 应用逻辑"
tone="accent"
height="156">
<Column gap="6">
<Text text="1. 检查左、中、右、下四个壳层区域边界是否干净,没有重叠、穿透或错位。" />
<Text text="2. 拖动 shell-left-right、shell-center-right、shell-top-bottom确认实时 resize 正常,并且会被最小尺寸正确 clamp。" />
<Text text="3. 点击 Document A / B / C确认中心区域只显示当前选中的 tab 占位内容。" />
<Text text="4. 这个测试不负责验证业务面板、数据绑定、命令系统,也不负责验证 new_editor 整体行为。" />
</Column>
</Card>
<Splitter
id="shell-top-bottom"
axis="vertical"
splitRatio="0.76"
splitterSize="10"
splitterHitSize="18"
primaryMin="340"
secondaryMin="120"
height="fill">
<Splitter
id="shell-left-right"
axis="horizontal"
splitRatio="0.22"
splitterSize="10"
splitterHitSize="18"
primaryMin="180"
secondaryMin="480"
height="fill">
<Card id="left-dock-placeholder" title="左侧 Dock 占位" subtitle="仅用于验证壳层 panel frame" height="fill">
<Column gap="8">
<Text text="这里用于检查 panel chrome 的边界、padding 和 split 稳定性。" />
</Column>
</Card>
<Splitter
id="shell-center-right"
axis="horizontal"
splitRatio="0.72"
splitterSize="10"
splitterHitSize="18"
primaryMin="280"
secondaryMin="200"
height="fill">
<TabStrip
id="document-tab-host"
tabHeaderHeight="34"
tabMinWidth="120"
height="fill">
<Tab id="document-tab-a" label="Document A" selected="true">
<Card title="文档占位 A" subtitle="当前选中 = Document A" height="fill">
<Column gap="8">
<Text text="中心主区域此时应该只渲染当前选中的 tab 占位内容。" />
</Column>
</Card>
</Tab>
<Tab id="document-tab-b" label="Document B">
<Card title="文档占位 B" subtitle="当前选中 = Document B" height="fill">
<Column gap="8">
<Text text="切到 Document B 后Document A 的内容应该被隐藏。" />
</Column>
</Card>
</Tab>
<Tab id="document-tab-c" label="Document C">
<Card title="文档占位 C" subtitle="当前选中 = Document C" height="fill">
<Column gap="8">
<Text text="这只是壳层 tab 占位,不是真实的 Editor 业务面板。" />
</Column>
</Card>
</Tab>
</TabStrip>
<Card id="right-dock-placeholder" title="右侧 Dock 占位" subtitle="仅用于验证壳层 panel frame" height="fill">
<Column gap="8">
<Text text="这里用于检查嵌套 split 和右侧占位 panel shell 是否稳定。" />
</Column>
</Card>
</Splitter>
</Splitter>
<Card id="bottom-dock-placeholder" title="底部 Dock 占位" subtitle="仅用于验证壳层 panel frame" height="fill">
<Column gap="8">
<Text text="这里用于检查底部 dock shell 和上下分割组合是否正常。" />
</Column>
</Card>
</Splitter>
</Column>
</View>

View File

@@ -4,5 +4,5 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
hInstance,
nCmdShow,
"editor.input.scroll_view");
"editor.shell.workspace_compose");
}

View File

@@ -1,19 +1,19 @@
set(EDITOR_UI_UNIT_TEST_SOURCES
test_editor_shell_asset_validation.cpp
test_input_modifier_tracker.cpp
test_editor_validation_registry.cpp
test_structured_editor_shell.cpp
test_ui_editor_panel_registry.cpp
test_ui_editor_collection_primitives.cpp
test_ui_editor_panel_chrome.cpp
test_ui_editor_workspace_controller.cpp
test_ui_editor_workspace_model.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
test_ui_editor_workspace_session.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
)
@@ -23,7 +23,6 @@ target_include_directories(editor_ui_tests
${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)

Some files were not shown because too many files have changed in this diff Show More