Build XCUI splitter foundation and test harness
This commit is contained in:
18
tests/UI/Editor/CMakeLists.txt
Normal file
18
tests/UI/Editor/CMakeLists.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
cmake_minimum_required(VERSION 3.15)
|
||||
|
||||
project(XCEngine_EditorUITests)
|
||||
|
||||
add_subdirectory(integration/shared)
|
||||
add_subdirectory(unit)
|
||||
add_subdirectory(integration)
|
||||
|
||||
add_custom_target(editor_ui_unit_tests
|
||||
DEPENDS
|
||||
editor_ui_tests
|
||||
)
|
||||
|
||||
add_custom_target(editor_ui_all_tests
|
||||
DEPENDS
|
||||
editor_ui_unit_tests
|
||||
editor_ui_integration_tests
|
||||
)
|
||||
8
tests/UI/Editor/integration/CMakeLists.txt
Normal file
8
tests/UI/Editor/integration/CMakeLists.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
add_subdirectory(input)
|
||||
add_subdirectory(layout)
|
||||
|
||||
add_custom_target(editor_ui_integration_tests
|
||||
DEPENDS
|
||||
editor_ui_input_integration_tests
|
||||
editor_ui_layout_integration_tests
|
||||
)
|
||||
22
tests/UI/Editor/integration/README.md
Normal file
22
tests/UI/Editor/integration/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Editor UI Integration Validation
|
||||
|
||||
This directory contains the manual XCUI validation system for editor-facing scenarios.
|
||||
|
||||
Structure:
|
||||
|
||||
- `shared/`: shared host, native renderer, screenshot helper, scenario registry
|
||||
- `input/`: input-related validation category
|
||||
- `layout/`: layout and shell-foundation validation category
|
||||
|
||||
Rules:
|
||||
|
||||
- One scenario directory maps to one executable.
|
||||
- Do not accumulate unrelated checks into one monolithic app.
|
||||
- Shared infrastructure belongs in `shared/`, not duplicated per scenario.
|
||||
- Screenshots are stored per scenario inside that scenario's `captures/` folder.
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
cmake --build build --config Debug --target editor_ui_integration_tests
|
||||
```
|
||||
10
tests/UI/Editor/integration/input/CMakeLists.txt
Normal file
10
tests/UI/Editor/integration/input/CMakeLists.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
add_subdirectory(keyboard_focus)
|
||||
add_subdirectory(pointer_states)
|
||||
add_subdirectory(shortcut_scope)
|
||||
|
||||
add_custom_target(editor_ui_input_integration_tests
|
||||
DEPENDS
|
||||
editor_ui_input_keyboard_focus_validation
|
||||
editor_ui_input_pointer_states_validation
|
||||
editor_ui_input_shortcut_scope_validation
|
||||
)
|
||||
9
tests/UI/Editor/integration/input/README.md
Normal file
9
tests/UI/Editor/integration/input/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Editor Input Integration
|
||||
|
||||
这个分类只放 editor 输入相关的手工验证场景。
|
||||
|
||||
规则:
|
||||
|
||||
- 一个场景目录对应一个独立 exe
|
||||
- 共享宿主层只放在 `integration/shared/`
|
||||
- 不允许把多个无关检查点塞进同一个 exe
|
||||
@@ -0,0 +1,35 @@
|
||||
set(EDITOR_UI_INPUT_KEYBOARD_FOCUS_RESOURCES
|
||||
View.xcui
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
|
||||
)
|
||||
|
||||
add_executable(editor_ui_input_keyboard_focus_validation WIN32
|
||||
main.cpp
|
||||
${EDITOR_UI_INPUT_KEYBOARD_FOCUS_RESOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_input_keyboard_focus_validation PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_input_keyboard_focus_validation PRIVATE
|
||||
UNICODE
|
||||
_UNICODE
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_input_keyboard_focus_validation PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_input_keyboard_focus_validation PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_input_keyboard_focus_validation PRIVATE
|
||||
editor_ui_integration_host
|
||||
)
|
||||
|
||||
set_target_properties(editor_ui_input_keyboard_focus_validation PROPERTIES
|
||||
OUTPUT_NAME "XCUIEditorInputKeyboardFocusValidation"
|
||||
)
|
||||
|
||||
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
|
||||
18
tests/UI/Editor/integration/input/keyboard_focus/README.md
Normal file
18
tests/UI/Editor/integration/input/keyboard_focus/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Keyboard Focus Validation
|
||||
|
||||
可执行 target:
|
||||
|
||||
- `editor_ui_input_keyboard_focus_validation`
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
build\tests\UI\Editor\integration\input\keyboard_focus\Debug\XCUIEditorInputKeyboardFocusValidation.exe
|
||||
```
|
||||
|
||||
检查点:
|
||||
|
||||
1. 按 `Tab`,焦点依次切换三个按钮
|
||||
2. 按 `Shift+Tab`,焦点反向切换
|
||||
3. 按 `Enter` 或 `Space`,当前 `focus` 按钮进入 `active`
|
||||
4. 松开按键后,`active` 清空
|
||||
30
tests/UI/Editor/integration/input/keyboard_focus/View.xcui
Normal file
30
tests/UI/Editor/integration/input/keyboard_focus/View.xcui
Normal file
@@ -0,0 +1,30 @@
|
||||
<View
|
||||
name="EditorInputKeyboardFocus"
|
||||
theme="../../shared/themes/editor_validation.xctheme">
|
||||
<Column padding="24" gap="16">
|
||||
<Card
|
||||
title="Editor Validation | Keyboard Focus"
|
||||
subtitle="当前批次:Tab 焦点遍历 | Enter / Space 激活"
|
||||
tone="accent"
|
||||
height="90">
|
||||
<Column gap="8">
|
||||
<Text text="这是 editor 侧验证场景,不承载 runtime 游戏 UI。" />
|
||||
<Text text="这一轮只检查键盘焦点和激活,不混入复杂 editor 面板。" />
|
||||
</Column>
|
||||
</Card>
|
||||
|
||||
<Card title="Keyboard Focus" subtitle="tab focus active" height="214">
|
||||
<Column gap="12">
|
||||
<Text text="只检查下面三个可聚焦按钮和右下角状态叠层。" />
|
||||
<Row gap="12">
|
||||
<Button id="focus-first" text="First Focus" />
|
||||
<Button id="focus-second" text="Second Focus" />
|
||||
<Button id="focus-third" text="Third Focus" />
|
||||
</Row>
|
||||
<Text text="1. 按 Tab:focus 应依次切到 First / Second / Third。" />
|
||||
<Text text="2. 按 Shift+Tab:focus 应反向切换。" />
|
||||
<Text text="3. focus 停在任一按钮后,按 Enter 或 Space:active 应出现;松开后 active 清空。" />
|
||||
</Column>
|
||||
</Card>
|
||||
</Column>
|
||||
</View>
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "Application.h"
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
|
||||
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
|
||||
hInstance,
|
||||
nCmdShow,
|
||||
"editor.input.keyboard_focus");
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
set(EDITOR_UI_INPUT_POINTER_STATES_RESOURCES
|
||||
View.xcui
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
|
||||
)
|
||||
|
||||
add_executable(editor_ui_input_pointer_states_validation WIN32
|
||||
main.cpp
|
||||
${EDITOR_UI_INPUT_POINTER_STATES_RESOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_input_pointer_states_validation PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_input_pointer_states_validation PRIVATE
|
||||
UNICODE
|
||||
_UNICODE
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_input_pointer_states_validation PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_input_pointer_states_validation PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_input_pointer_states_validation PRIVATE
|
||||
editor_ui_integration_host
|
||||
)
|
||||
|
||||
set_target_properties(editor_ui_input_pointer_states_validation PROPERTIES
|
||||
OUTPUT_NAME "XCUIEditorInputPointerStatesValidation"
|
||||
)
|
||||
|
||||
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
|
||||
17
tests/UI/Editor/integration/input/pointer_states/README.md
Normal file
17
tests/UI/Editor/integration/input/pointer_states/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Pointer States Validation
|
||||
|
||||
可执行 target:
|
||||
|
||||
- `editor_ui_input_pointer_states_validation`
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
build\tests\UI\Editor\integration\input\pointer_states\Debug\XCUIEditorInputPointerStatesValidation.exe
|
||||
```
|
||||
|
||||
检查点:
|
||||
|
||||
1. hover 左侧按钮,只应变化 `hover`
|
||||
2. 按住中间按钮,应看到 `focus`、`active`、`capture`
|
||||
3. 拖到右侧再松开,应看到 `capture` 清空,route 转到新的目标
|
||||
30
tests/UI/Editor/integration/input/pointer_states/View.xcui
Normal file
30
tests/UI/Editor/integration/input/pointer_states/View.xcui
Normal file
@@ -0,0 +1,30 @@
|
||||
<View
|
||||
name="EditorInputPointerStates"
|
||||
theme="../../shared/themes/editor_validation.xctheme">
|
||||
<Column padding="24" gap="16">
|
||||
<Card
|
||||
title="Editor Validation | Pointer States"
|
||||
subtitle="当前批次:鼠标 hover / focus / active / capture"
|
||||
tone="accent"
|
||||
height="90">
|
||||
<Column gap="8">
|
||||
<Text text="这是 editor 侧验证场景,不承载 runtime 游戏 UI。" />
|
||||
<Text text="这一轮只检查鼠标输入状态,不混入别的控件实验。" />
|
||||
</Column>
|
||||
</Card>
|
||||
|
||||
<Card title="Pointer Input" subtitle="hover focus active capture" height="196">
|
||||
<Column gap="12">
|
||||
<Text text="这一轮只需要检查下面这三个按钮。" />
|
||||
<Row gap="12">
|
||||
<Button id="input-hover" text="Hover / Focus" />
|
||||
<Button id="input-capture" text="Pointer Capture" capturePointer="true" />
|
||||
<Button id="input-route" text="Route Target" />
|
||||
</Row>
|
||||
<Text text="1. 鼠标移到左侧按钮:hover 应变化,focus 保持空。" />
|
||||
<Text text="2. 按住中间按钮:focus、active、capture 都应留在中间。" />
|
||||
<Text text="3. 拖到右侧再松开:hover 移到右侧,capture 清空,focus 仍留中间。" />
|
||||
</Column>
|
||||
</Card>
|
||||
</Column>
|
||||
</View>
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "Application.h"
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
|
||||
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
|
||||
hInstance,
|
||||
nCmdShow,
|
||||
"editor.input.pointer_states");
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
set(EDITOR_UI_INPUT_SHORTCUT_SCOPE_RESOURCES
|
||||
View.xcui
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
|
||||
)
|
||||
|
||||
add_executable(editor_ui_input_shortcut_scope_validation WIN32
|
||||
main.cpp
|
||||
${EDITOR_UI_INPUT_SHORTCUT_SCOPE_RESOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_input_shortcut_scope_validation PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_input_shortcut_scope_validation PRIVATE
|
||||
UNICODE
|
||||
_UNICODE
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_input_shortcut_scope_validation PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_input_shortcut_scope_validation PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_input_shortcut_scope_validation PRIVATE
|
||||
editor_ui_integration_host
|
||||
)
|
||||
|
||||
set_target_properties(editor_ui_input_shortcut_scope_validation PROPERTIES
|
||||
OUTPUT_NAME "XCUIEditorInputShortcutScopeValidation"
|
||||
)
|
||||
|
||||
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
|
||||
69
tests/UI/Editor/integration/input/shortcut_scope/View.xcui
Normal file
69
tests/UI/Editor/integration/input/shortcut_scope/View.xcui
Normal file
@@ -0,0 +1,69 @@
|
||||
<View
|
||||
name="EditorInputShortcutScope"
|
||||
theme="../../shared/themes/editor_validation.xctheme"
|
||||
shortcut="Ctrl+P"
|
||||
shortcutCommand="global.command"
|
||||
shortcutScope="global">
|
||||
<Column padding="20" gap="12">
|
||||
<Card
|
||||
title="Editor Validation | Shortcut Scope"
|
||||
subtitle="验证功能:Editor shortcut scope 路由与 text input suppression"
|
||||
tone="accent"
|
||||
height="100">
|
||||
<Column gap="6">
|
||||
<Text text="功能 1:验证 Ctrl+P 在 Widget / Panel / Window / Global 间按优先级命中 shortcut。" />
|
||||
<Text text="功能 2:验证 Text Input Proxy 会抑制 Ctrl+P 和 Tab 焦点遍历。" />
|
||||
</Column>
|
||||
</Card>
|
||||
|
||||
<Button id="global-focus" text="Global Focus" />
|
||||
|
||||
<Card
|
||||
id="window-shell"
|
||||
title="Window Scope"
|
||||
subtitle="Ctrl+P -> window.command"
|
||||
shortcutScopeRoot="window"
|
||||
shortcut="Ctrl+P"
|
||||
shortcutCommand="window.command"
|
||||
shortcutScope="window">
|
||||
<Column gap="10">
|
||||
<Text text="先检查优先级:widget > panel > window > global。" />
|
||||
<Button id="window-focus" text="Window Focus" />
|
||||
|
||||
<Card
|
||||
id="panel-shell"
|
||||
title="Panel Scope"
|
||||
subtitle="Ctrl+P -> panel.command"
|
||||
shortcutScopeRoot="panel"
|
||||
shortcut="Ctrl+P"
|
||||
shortcutCommand="panel.command"
|
||||
shortcutScope="panel">
|
||||
<Column gap="10">
|
||||
<Button id="panel-focus" text="Panel Focus" />
|
||||
|
||||
<Card
|
||||
id="widget-shell"
|
||||
title="Widget Scope"
|
||||
subtitle="Ctrl+P -> widget.command"
|
||||
tone="accent-alt"
|
||||
shortcutScopeRoot="widget"
|
||||
shortcut="Ctrl+P"
|
||||
shortcutCommand="widget.command"
|
||||
shortcutScope="widget">
|
||||
<Column gap="10">
|
||||
<Button id="widget-focus" text="Widget Focus" />
|
||||
</Column>
|
||||
</Card>
|
||||
|
||||
<Button id="text-input" text="Text Input Proxy" textInput="true" />
|
||||
<Text text="操作指引:" />
|
||||
<Text text="1. 依次点 Widget / Panel / Window / Global Focus,再按 Ctrl+P。" />
|
||||
<Text text="2. 右下角 Recent shortcut 应分别显示 widget / panel / window / global,且状态为 handled。" />
|
||||
<Text text="3. 点 Text Input Proxy 再按 Ctrl+P,Recent shortcut 状态应变为 suppressed。" />
|
||||
<Text text="4. 保持 Text Input Proxy focus 再按 Tab,Result 应显示 focus traversal suppressed,focus 不应跳走。" />
|
||||
</Column>
|
||||
</Card>
|
||||
</Column>
|
||||
</Card>
|
||||
</Column>
|
||||
</View>
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "Application.h"
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
|
||||
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
|
||||
hInstance,
|
||||
nCmdShow,
|
||||
"editor.input.shortcut_scope");
|
||||
}
|
||||
6
tests/UI/Editor/integration/layout/CMakeLists.txt
Normal file
6
tests/UI/Editor/integration/layout/CMakeLists.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
add_subdirectory(splitter_resize)
|
||||
|
||||
add_custom_target(editor_ui_layout_integration_tests
|
||||
DEPENDS
|
||||
editor_ui_layout_splitter_resize_validation
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
set(EDITOR_UI_LAYOUT_SPLITTER_RESIZE_RESOURCES
|
||||
View.xcui
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
|
||||
)
|
||||
|
||||
add_executable(editor_ui_layout_splitter_resize_validation WIN32
|
||||
main.cpp
|
||||
${EDITOR_UI_LAYOUT_SPLITTER_RESIZE_RESOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_layout_splitter_resize_validation PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_layout_splitter_resize_validation PRIVATE
|
||||
UNICODE
|
||||
_UNICODE
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_layout_splitter_resize_validation PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_layout_splitter_resize_validation PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_layout_splitter_resize_validation PRIVATE
|
||||
editor_ui_integration_host
|
||||
)
|
||||
|
||||
set_target_properties(editor_ui_layout_splitter_resize_validation PROPERTIES
|
||||
OUTPUT_NAME "XCUIEditorLayoutSplitterResizeValidation"
|
||||
)
|
||||
|
||||
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
|
||||
39
tests/UI/Editor/integration/layout/splitter_resize/View.xcui
Normal file
39
tests/UI/Editor/integration/layout/splitter_resize/View.xcui
Normal file
@@ -0,0 +1,39 @@
|
||||
<View
|
||||
name="EditorSplitterResizeValidation"
|
||||
theme="../../shared/themes/editor_validation.xctheme">
|
||||
<Column width="fill" height="fill" padding="20" gap="12">
|
||||
<Card
|
||||
title="功能:Splitter / pane resize"
|
||||
subtitle="这一轮只检查分割条拖拽和最小尺寸 clamp"
|
||||
tone="accent"
|
||||
height="128">
|
||||
<Column gap="6">
|
||||
<Text text="1. 鼠标移到中间 divider:右下角 Hover 应落到 workspace-splitter。" />
|
||||
<Text text="2. 按住左键拖拽:左右 pane 宽度应实时变化,Result 应出现 Splitter drag started / Splitter resized。" />
|
||||
<Text text="3. 向左右极限拖拽:布局应被 primaryMin / secondaryMin clamp 住,不应穿透。" />
|
||||
<Text text="4. 松开左键:Result 应显示 Splitter drag finished。" />
|
||||
</Column>
|
||||
</Card>
|
||||
|
||||
<Splitter
|
||||
id="workspace-splitter"
|
||||
axis="horizontal"
|
||||
splitRatio="0.38"
|
||||
splitterSize="10"
|
||||
splitterHitSize="18"
|
||||
primaryMin="180"
|
||||
secondaryMin="220"
|
||||
height="fill">
|
||||
<Card id="left-pane" title="Left Empty Pane" subtitle="min 180" height="fill">
|
||||
<Column gap="8">
|
||||
<Text text="这里只保留空 pane,用来观察 resize。" />
|
||||
</Column>
|
||||
</Card>
|
||||
<Card id="right-pane" title="Right Empty Pane" subtitle="min 220" height="fill">
|
||||
<Column gap="8">
|
||||
<Text text="拖拽过程中不应出现翻转、穿透或抖动。" />
|
||||
</Column>
|
||||
</Card>
|
||||
</Splitter>
|
||||
</Column>
|
||||
</View>
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "Application.h"
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
|
||||
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
|
||||
hInstance,
|
||||
nCmdShow,
|
||||
"editor.layout.splitter_resize");
|
||||
}
|
||||
57
tests/UI/Editor/integration/shared/CMakeLists.txt
Normal file
57
tests/UI/Editor/integration/shared/CMakeLists.txt
Normal file
@@ -0,0 +1,57 @@
|
||||
file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH)
|
||||
|
||||
add_library(editor_ui_validation_registry STATIC
|
||||
src/EditorValidationScenario.cpp
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_validation_registry
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_validation_registry
|
||||
PUBLIC
|
||||
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_validation_registry PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_validation_registry PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_validation_registry
|
||||
PUBLIC
|
||||
XCEngine
|
||||
)
|
||||
|
||||
add_library(editor_ui_integration_host STATIC
|
||||
src/Application.cpp
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_integration_host
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/new_editor/include
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
)
|
||||
|
||||
target_compile_definitions(editor_ui_integration_host
|
||||
PUBLIC
|
||||
UNICODE
|
||||
_UNICODE
|
||||
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_integration_host PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_integration_host PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
target_link_libraries(editor_ui_integration_host
|
||||
PUBLIC
|
||||
editor_ui_validation_registry
|
||||
XCNewEditorHost
|
||||
)
|
||||
782
tests/UI/Editor/integration/shared/src/Application.cpp
Normal file
782
tests/UI/Editor/integration/shared/src/Application.cpp
Normal file
@@ -0,0 +1,782 @@
|
||||
#include "Application.h"
|
||||
|
||||
#include <XCEngine/Input/InputTypes.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
|
||||
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
|
||||
#endif
|
||||
|
||||
namespace XCEngine::Tests::EditorUI {
|
||||
|
||||
namespace {
|
||||
|
||||
using ::XCEngine::UI::UIColor;
|
||||
using ::XCEngine::UI::UIDrawData;
|
||||
using ::XCEngine::UI::UIDrawList;
|
||||
using ::XCEngine::UI::UIInputEvent;
|
||||
using ::XCEngine::UI::UIInputEventType;
|
||||
using ::XCEngine::UI::UIPoint;
|
||||
using ::XCEngine::UI::UIPointerButton;
|
||||
using ::XCEngine::UI::UIRect;
|
||||
using ::XCEngine::UI::Runtime::UIScreenFrameInput;
|
||||
using ::XCEngine::Input::KeyCode;
|
||||
|
||||
constexpr const wchar_t* kWindowClassName = L"XCUIEditorValidationHost";
|
||||
constexpr const wchar_t* kWindowTitle = L"XCUI Editor Validation";
|
||||
constexpr auto kReloadPollInterval = std::chrono::milliseconds(150);
|
||||
|
||||
constexpr UIColor kOverlayBgColor(0.10f, 0.10f, 0.10f, 0.95f);
|
||||
constexpr UIColor kOverlayBorderColor(0.25f, 0.25f, 0.25f, 1.0f);
|
||||
constexpr UIColor kOverlayTextPrimary(0.93f, 0.93f, 0.93f, 1.0f);
|
||||
constexpr UIColor kOverlayTextMuted(0.70f, 0.70f, 0.70f, 1.0f);
|
||||
constexpr UIColor kOverlaySuccess(0.82f, 0.82f, 0.82f, 1.0f);
|
||||
constexpr UIColor kOverlayFallback(0.56f, 0.56f, 0.56f, 1.0f);
|
||||
|
||||
Application* GetApplicationFromWindow(HWND hwnd) {
|
||||
return reinterpret_cast<Application*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
|
||||
}
|
||||
|
||||
std::filesystem::path GetRepoRootPath() {
|
||||
std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT;
|
||||
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
|
||||
root = root.substr(1u, root.size() - 2u);
|
||||
}
|
||||
return std::filesystem::path(root).lexically_normal();
|
||||
}
|
||||
|
||||
std::string TruncateText(const std::string& text, std::size_t maxLength) {
|
||||
if (text.size() <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (maxLength <= 3u) {
|
||||
return text.substr(0, maxLength);
|
||||
}
|
||||
|
||||
return text.substr(0, maxLength - 3u) + "...";
|
||||
}
|
||||
|
||||
std::string ExtractStateKeyTail(const std::string& stateKey) {
|
||||
if (stateKey.empty()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const std::size_t separator = stateKey.find_last_of('/');
|
||||
if (separator == std::string::npos || separator + 1u >= stateKey.size()) {
|
||||
return stateKey;
|
||||
}
|
||||
|
||||
return stateKey.substr(separator + 1u);
|
||||
}
|
||||
|
||||
std::string FormatFloat(float value) {
|
||||
std::ostringstream stream;
|
||||
stream.setf(std::ios::fixed, std::ios::floatfield);
|
||||
stream.precision(1);
|
||||
stream << value;
|
||||
return stream.str();
|
||||
}
|
||||
|
||||
std::string FormatPoint(const UIPoint& point) {
|
||||
return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")";
|
||||
}
|
||||
|
||||
std::string FormatRect(const UIRect& rect) {
|
||||
return "(" + FormatFloat(rect.x) +
|
||||
", " + FormatFloat(rect.y) +
|
||||
", " + FormatFloat(rect.width) +
|
||||
", " + FormatFloat(rect.height) +
|
||||
")";
|
||||
}
|
||||
|
||||
std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) {
|
||||
switch (wParam) {
|
||||
case 'A': return static_cast<std::int32_t>(KeyCode::A);
|
||||
case 'B': return static_cast<std::int32_t>(KeyCode::B);
|
||||
case 'C': return static_cast<std::int32_t>(KeyCode::C);
|
||||
case 'D': return static_cast<std::int32_t>(KeyCode::D);
|
||||
case 'E': return static_cast<std::int32_t>(KeyCode::E);
|
||||
case 'F': return static_cast<std::int32_t>(KeyCode::F);
|
||||
case 'G': return static_cast<std::int32_t>(KeyCode::G);
|
||||
case 'H': return static_cast<std::int32_t>(KeyCode::H);
|
||||
case 'I': return static_cast<std::int32_t>(KeyCode::I);
|
||||
case 'J': return static_cast<std::int32_t>(KeyCode::J);
|
||||
case 'K': return static_cast<std::int32_t>(KeyCode::K);
|
||||
case 'L': return static_cast<std::int32_t>(KeyCode::L);
|
||||
case 'M': return static_cast<std::int32_t>(KeyCode::M);
|
||||
case 'N': return static_cast<std::int32_t>(KeyCode::N);
|
||||
case 'O': return static_cast<std::int32_t>(KeyCode::O);
|
||||
case 'P': return static_cast<std::int32_t>(KeyCode::P);
|
||||
case 'Q': return static_cast<std::int32_t>(KeyCode::Q);
|
||||
case 'R': return static_cast<std::int32_t>(KeyCode::R);
|
||||
case 'S': return static_cast<std::int32_t>(KeyCode::S);
|
||||
case 'T': return static_cast<std::int32_t>(KeyCode::T);
|
||||
case 'U': return static_cast<std::int32_t>(KeyCode::U);
|
||||
case 'V': return static_cast<std::int32_t>(KeyCode::V);
|
||||
case 'W': return static_cast<std::int32_t>(KeyCode::W);
|
||||
case 'X': return static_cast<std::int32_t>(KeyCode::X);
|
||||
case 'Y': return static_cast<std::int32_t>(KeyCode::Y);
|
||||
case 'Z': return static_cast<std::int32_t>(KeyCode::Z);
|
||||
case '0': return static_cast<std::int32_t>(KeyCode::Zero);
|
||||
case '1': return static_cast<std::int32_t>(KeyCode::One);
|
||||
case '2': return static_cast<std::int32_t>(KeyCode::Two);
|
||||
case '3': return static_cast<std::int32_t>(KeyCode::Three);
|
||||
case '4': return static_cast<std::int32_t>(KeyCode::Four);
|
||||
case '5': return static_cast<std::int32_t>(KeyCode::Five);
|
||||
case '6': return static_cast<std::int32_t>(KeyCode::Six);
|
||||
case '7': return static_cast<std::int32_t>(KeyCode::Seven);
|
||||
case '8': return static_cast<std::int32_t>(KeyCode::Eight);
|
||||
case '9': return static_cast<std::int32_t>(KeyCode::Nine);
|
||||
case VK_SPACE: return static_cast<std::int32_t>(KeyCode::Space);
|
||||
case VK_TAB: return static_cast<std::int32_t>(KeyCode::Tab);
|
||||
case VK_RETURN: return static_cast<std::int32_t>(KeyCode::Enter);
|
||||
case VK_ESCAPE: return static_cast<std::int32_t>(KeyCode::Escape);
|
||||
case VK_SHIFT: return static_cast<std::int32_t>(KeyCode::LeftShift);
|
||||
case VK_CONTROL: return static_cast<std::int32_t>(KeyCode::LeftCtrl);
|
||||
case VK_MENU: return static_cast<std::int32_t>(KeyCode::LeftAlt);
|
||||
case VK_UP: return static_cast<std::int32_t>(KeyCode::Up);
|
||||
case VK_DOWN: return static_cast<std::int32_t>(KeyCode::Down);
|
||||
case VK_LEFT: return static_cast<std::int32_t>(KeyCode::Left);
|
||||
case VK_RIGHT: return static_cast<std::int32_t>(KeyCode::Right);
|
||||
case VK_HOME: return static_cast<std::int32_t>(KeyCode::Home);
|
||||
case VK_END: return static_cast<std::int32_t>(KeyCode::End);
|
||||
case VK_PRIOR: return static_cast<std::int32_t>(KeyCode::PageUp);
|
||||
case VK_NEXT: return static_cast<std::int32_t>(KeyCode::PageDown);
|
||||
case VK_DELETE: return static_cast<std::int32_t>(KeyCode::Delete);
|
||||
case VK_BACK: return static_cast<std::int32_t>(KeyCode::Backspace);
|
||||
case VK_F1: return static_cast<std::int32_t>(KeyCode::F1);
|
||||
case VK_F2: return static_cast<std::int32_t>(KeyCode::F2);
|
||||
case VK_F3: return static_cast<std::int32_t>(KeyCode::F3);
|
||||
case VK_F4: return static_cast<std::int32_t>(KeyCode::F4);
|
||||
case VK_F5: return static_cast<std::int32_t>(KeyCode::F5);
|
||||
case VK_F6: return static_cast<std::int32_t>(KeyCode::F6);
|
||||
case VK_F7: return static_cast<std::int32_t>(KeyCode::F7);
|
||||
case VK_F8: return static_cast<std::int32_t>(KeyCode::F8);
|
||||
case VK_F9: return static_cast<std::int32_t>(KeyCode::F9);
|
||||
case VK_F10: return static_cast<std::int32_t>(KeyCode::F10);
|
||||
case VK_F11: return static_cast<std::int32_t>(KeyCode::F11);
|
||||
case VK_F12: return static_cast<std::int32_t>(KeyCode::F12);
|
||||
default: return static_cast<std::int32_t>(KeyCode::None);
|
||||
}
|
||||
}
|
||||
|
||||
bool IsRepeatKeyMessage(LPARAM lParam) {
|
||||
return (static_cast<unsigned long>(lParam) & (1ul << 30)) != 0ul;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Application::Application(std::string requestedScenarioId)
|
||||
: m_screenPlayer(m_documentHost)
|
||||
, m_requestedScenarioId(std::move(requestedScenarioId)) {
|
||||
}
|
||||
|
||||
int Application::Run(HINSTANCE hInstance, int nCmdShow) {
|
||||
if (!Initialize(hInstance, nCmdShow)) {
|
||||
Shutdown();
|
||||
return 1;
|
||||
}
|
||||
|
||||
MSG message = {};
|
||||
while (message.message != WM_QUIT) {
|
||||
if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) {
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
continue;
|
||||
}
|
||||
|
||||
RenderFrame();
|
||||
Sleep(8);
|
||||
}
|
||||
|
||||
Shutdown();
|
||||
return static_cast<int>(message.wParam);
|
||||
}
|
||||
|
||||
bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) {
|
||||
m_hInstance = hInstance;
|
||||
|
||||
WNDCLASSEXW windowClass = {};
|
||||
windowClass.cbSize = sizeof(windowClass);
|
||||
windowClass.style = CS_HREDRAW | CS_VREDRAW;
|
||||
windowClass.lpfnWndProc = &Application::WndProc;
|
||||
windowClass.hInstance = hInstance;
|
||||
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
windowClass.lpszClassName = kWindowClassName;
|
||||
|
||||
m_windowClassAtom = RegisterClassExW(&windowClass);
|
||||
if (m_windowClassAtom == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
m_hwnd = CreateWindowExW(
|
||||
0,
|
||||
kWindowClassName,
|
||||
kWindowTitle,
|
||||
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
1440,
|
||||
900,
|
||||
nullptr,
|
||||
nullptr,
|
||||
hInstance,
|
||||
this);
|
||||
if (m_hwnd == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ShowWindow(m_hwnd, nCmdShow);
|
||||
UpdateWindow(m_hwnd);
|
||||
|
||||
if (!m_renderer.Initialize(m_hwnd)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
m_startTime = std::chrono::steady_clock::now();
|
||||
m_lastFrameTime = m_startTime;
|
||||
const EditorValidationScenario* initialScenario = m_requestedScenarioId.empty()
|
||||
? &GetDefaultEditorValidationScenario()
|
||||
: FindEditorValidationScenario(m_requestedScenarioId);
|
||||
if (initialScenario == nullptr) {
|
||||
initialScenario = &GetDefaultEditorValidationScenario();
|
||||
}
|
||||
m_autoScreenshot.Initialize(initialScenario->captureRootPath);
|
||||
LoadStructuredScreen("startup");
|
||||
return true;
|
||||
}
|
||||
|
||||
void Application::Shutdown() {
|
||||
m_autoScreenshot.Shutdown();
|
||||
m_screenPlayer.Unload();
|
||||
m_trackedFiles.clear();
|
||||
m_screenAsset = {};
|
||||
m_useStructuredScreen = false;
|
||||
m_runtimeStatus.clear();
|
||||
m_runtimeError.clear();
|
||||
m_frameIndex = 0;
|
||||
|
||||
m_renderer.Shutdown();
|
||||
|
||||
if (m_hwnd != nullptr && IsWindow(m_hwnd)) {
|
||||
DestroyWindow(m_hwnd);
|
||||
}
|
||||
m_hwnd = nullptr;
|
||||
|
||||
if (m_windowClassAtom != 0 && m_hInstance != nullptr) {
|
||||
UnregisterClassW(kWindowClassName, m_hInstance);
|
||||
m_windowClassAtom = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void Application::RenderFrame() {
|
||||
if (m_hwnd == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
RECT clientRect = {};
|
||||
GetClientRect(m_hwnd, &clientRect);
|
||||
const float width = static_cast<float>((std::max)(clientRect.right - clientRect.left, 1L));
|
||||
const float height = static_cast<float>((std::max)(clientRect.bottom - clientRect.top, 1L));
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
double deltaTimeSeconds = std::chrono::duration<double>(now - m_lastFrameTime).count();
|
||||
if (deltaTimeSeconds <= 0.0) {
|
||||
deltaTimeSeconds = 1.0 / 60.0;
|
||||
}
|
||||
m_lastFrameTime = now;
|
||||
|
||||
RefreshStructuredScreen();
|
||||
std::vector<UIInputEvent> frameEvents = std::move(m_pendingInputEvents);
|
||||
m_pendingInputEvents.clear();
|
||||
|
||||
UIDrawData drawData = {};
|
||||
if (m_useStructuredScreen && m_screenPlayer.IsLoaded()) {
|
||||
UIScreenFrameInput input = {};
|
||||
input.viewportRect = UIRect(0.0f, 0.0f, width, height);
|
||||
input.events = std::move(frameEvents);
|
||||
input.deltaTimeSeconds = deltaTimeSeconds;
|
||||
input.frameIndex = ++m_frameIndex;
|
||||
input.focused = GetForegroundWindow() == m_hwnd;
|
||||
|
||||
const auto& frame = m_screenPlayer.Update(input);
|
||||
for (const auto& drawList : frame.drawData.GetDrawLists()) {
|
||||
drawData.AddDrawList(drawList);
|
||||
}
|
||||
|
||||
m_runtimeStatus = m_activeScenario != nullptr
|
||||
? m_activeScenario->displayName
|
||||
: "Editor UI Validation";
|
||||
m_runtimeError = frame.errorMessage;
|
||||
}
|
||||
|
||||
if (drawData.Empty()) {
|
||||
m_runtimeStatus = "Editor UI Validation | Load Error";
|
||||
if (m_runtimeError.empty() && !m_screenPlayer.IsLoaded()) {
|
||||
m_runtimeError = m_screenPlayer.GetLastError();
|
||||
}
|
||||
}
|
||||
|
||||
AppendRuntimeOverlay(drawData, width, height);
|
||||
|
||||
const bool framePresented = m_renderer.Render(drawData);
|
||||
m_autoScreenshot.CaptureIfRequested(
|
||||
m_renderer,
|
||||
drawData,
|
||||
static_cast<unsigned int>(width),
|
||||
static_cast<unsigned int>(height),
|
||||
framePresented);
|
||||
}
|
||||
|
||||
void Application::OnResize(UINT width, UINT height) {
|
||||
if (width == 0 || height == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_renderer.Resize(width, height);
|
||||
}
|
||||
|
||||
void Application::QueuePointerEvent(UIInputEventType type, UIPointerButton button, WPARAM wParam, LPARAM lParam) {
|
||||
UIInputEvent event = {};
|
||||
event.type = type;
|
||||
event.pointerButton = button;
|
||||
event.position = UIPoint(
|
||||
static_cast<float>(GET_X_LPARAM(lParam)),
|
||||
static_cast<float>(GET_Y_LPARAM(lParam)));
|
||||
event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<size_t>(wParam));
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
void Application::QueuePointerLeaveEvent() {
|
||||
UIInputEvent event = {};
|
||||
event.type = UIInputEventType::PointerLeave;
|
||||
if (m_hwnd != nullptr) {
|
||||
POINT clientPoint = {};
|
||||
GetCursorPos(&clientPoint);
|
||||
ScreenToClient(m_hwnd, &clientPoint);
|
||||
event.position = UIPoint(static_cast<float>(clientPoint.x), static_cast<float>(clientPoint.y));
|
||||
}
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
void Application::QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam) {
|
||||
if (m_hwnd == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
POINT screenPoint = {
|
||||
GET_X_LPARAM(lParam),
|
||||
GET_Y_LPARAM(lParam)
|
||||
};
|
||||
ScreenToClient(m_hwnd, &screenPoint);
|
||||
|
||||
UIInputEvent event = {};
|
||||
event.type = UIInputEventType::PointerWheel;
|
||||
event.position = UIPoint(static_cast<float>(screenPoint.x), static_cast<float>(screenPoint.y));
|
||||
event.wheelDelta = static_cast<float>(wheelDelta);
|
||||
event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<size_t>(wParam));
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
void Application::QueueKeyEvent(UIInputEventType type, WPARAM wParam, LPARAM lParam) {
|
||||
UIInputEvent event = {};
|
||||
event.type = type;
|
||||
event.keyCode = MapVirtualKeyToUIKeyCode(wParam);
|
||||
event.modifiers = m_inputModifierTracker.ApplyKeyMessage(type, wParam, lParam);
|
||||
event.repeat = IsRepeatKeyMessage(lParam);
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
void Application::QueueCharacterEvent(WPARAM wParam, LPARAM) {
|
||||
UIInputEvent event = {};
|
||||
event.type = UIInputEventType::Character;
|
||||
event.character = static_cast<std::uint32_t>(wParam);
|
||||
event.modifiers = m_inputModifierTracker.GetCurrentModifiers();
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
void Application::QueueWindowFocusEvent(UIInputEventType type) {
|
||||
UIInputEvent event = {};
|
||||
event.type = type;
|
||||
m_pendingInputEvents.push_back(event);
|
||||
}
|
||||
|
||||
bool Application::LoadStructuredScreen(const char* triggerReason) {
|
||||
(void)triggerReason;
|
||||
std::string scenarioLoadWarning = {};
|
||||
const EditorValidationScenario* scenario = m_requestedScenarioId.empty()
|
||||
? &GetDefaultEditorValidationScenario()
|
||||
: FindEditorValidationScenario(m_requestedScenarioId);
|
||||
if (scenario == nullptr) {
|
||||
scenario = &GetDefaultEditorValidationScenario();
|
||||
scenarioLoadWarning = "Unknown validation scenario: " + m_requestedScenarioId;
|
||||
}
|
||||
|
||||
m_activeScenario = scenario;
|
||||
m_screenAsset = {};
|
||||
m_screenAsset.screenId = scenario->id;
|
||||
m_screenAsset.documentPath = scenario->documentPath.string();
|
||||
m_screenAsset.themePath = scenario->themePath.string();
|
||||
|
||||
const bool loaded = m_screenPlayer.Load(m_screenAsset);
|
||||
m_useStructuredScreen = loaded;
|
||||
m_runtimeStatus = loaded ? scenario->displayName : "Editor UI Validation | Load Error";
|
||||
m_runtimeError = loaded
|
||||
? scenarioLoadWarning
|
||||
: (scenarioLoadWarning.empty()
|
||||
? m_screenPlayer.GetLastError()
|
||||
: scenarioLoadWarning + " | " + m_screenPlayer.GetLastError());
|
||||
RebuildTrackedFileStates();
|
||||
return loaded;
|
||||
}
|
||||
|
||||
void Application::RefreshStructuredScreen() {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (m_lastReloadPollTime.time_since_epoch().count() != 0 &&
|
||||
now - m_lastReloadPollTime < kReloadPollInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_lastReloadPollTime = now;
|
||||
if (DetectTrackedFileChange()) {
|
||||
LoadStructuredScreen("reload");
|
||||
}
|
||||
}
|
||||
|
||||
void Application::RebuildTrackedFileStates() {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
m_trackedFiles.clear();
|
||||
std::unordered_set<std::string> seenPaths = {};
|
||||
std::error_code errorCode = {};
|
||||
|
||||
auto appendTrackedPath = [&](const std::string& rawPath) {
|
||||
if (rawPath.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fs::path normalizedPath = fs::path(rawPath).lexically_normal();
|
||||
const std::string key = normalizedPath.string();
|
||||
if (!seenPaths.insert(key).second) {
|
||||
return;
|
||||
}
|
||||
|
||||
TrackedFileState state = {};
|
||||
state.path = normalizedPath;
|
||||
state.exists = fs::exists(normalizedPath, errorCode);
|
||||
errorCode.clear();
|
||||
if (state.exists) {
|
||||
state.writeTime = fs::last_write_time(normalizedPath, errorCode);
|
||||
errorCode.clear();
|
||||
}
|
||||
m_trackedFiles.push_back(std::move(state));
|
||||
};
|
||||
|
||||
appendTrackedPath(m_screenAsset.documentPath);
|
||||
appendTrackedPath(m_screenAsset.themePath);
|
||||
|
||||
if (const auto* document = m_screenPlayer.GetDocument(); document != nullptr) {
|
||||
for (const std::string& dependency : document->dependencies) {
|
||||
appendTrackedPath(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Application::DetectTrackedFileChange() const {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
std::error_code errorCode = {};
|
||||
for (const TrackedFileState& trackedFile : m_trackedFiles) {
|
||||
const bool existsNow = fs::exists(trackedFile.path, errorCode);
|
||||
errorCode.clear();
|
||||
if (existsNow != trackedFile.exists) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!existsNow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto writeTimeNow = fs::last_write_time(trackedFile.path, errorCode);
|
||||
errorCode.clear();
|
||||
if (writeTimeNow != trackedFile.writeTime) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float height) const {
|
||||
const bool authoredMode = m_useStructuredScreen && m_screenPlayer.IsLoaded();
|
||||
const float panelWidth = authoredMode ? 460.0f : 360.0f;
|
||||
std::vector<std::string> detailLines = {};
|
||||
detailLines.push_back(
|
||||
authoredMode
|
||||
? "Hot reload watches authored UI resources."
|
||||
: "Authored validation scene failed to load.");
|
||||
if (m_activeScenario != nullptr) {
|
||||
detailLines.push_back("Scenario: " + m_activeScenario->id);
|
||||
}
|
||||
|
||||
if (authoredMode) {
|
||||
const auto& inputDebug = m_documentHost.GetInputDebugSnapshot();
|
||||
detailLines.push_back(
|
||||
"Hover | Focus: " +
|
||||
ExtractStateKeyTail(inputDebug.hoveredStateKey) +
|
||||
" | " +
|
||||
ExtractStateKeyTail(inputDebug.focusedStateKey));
|
||||
detailLines.push_back(
|
||||
"Active | Capture: " +
|
||||
ExtractStateKeyTail(inputDebug.activeStateKey) +
|
||||
" | " +
|
||||
ExtractStateKeyTail(inputDebug.captureStateKey));
|
||||
detailLines.push_back(
|
||||
"Scope W/P/Wg: " +
|
||||
ExtractStateKeyTail(inputDebug.windowScopeStateKey) +
|
||||
" | " +
|
||||
ExtractStateKeyTail(inputDebug.panelScopeStateKey) +
|
||||
" | " +
|
||||
ExtractStateKeyTail(inputDebug.widgetScopeStateKey));
|
||||
detailLines.push_back(
|
||||
std::string("Text input: ") +
|
||||
(inputDebug.textInputActive ? "active" : "idle"));
|
||||
if (!inputDebug.recentShortcutCommandId.empty()) {
|
||||
detailLines.push_back(
|
||||
"Recent shortcut: " +
|
||||
inputDebug.recentShortcutScope +
|
||||
" -> " +
|
||||
inputDebug.recentShortcutCommandId);
|
||||
detailLines.push_back(
|
||||
std::string("Recent shortcut state: ") +
|
||||
(inputDebug.recentShortcutHandled
|
||||
? "handled"
|
||||
: (inputDebug.recentShortcutSuppressed ? "suppressed" : "observed")) +
|
||||
" @ " +
|
||||
ExtractStateKeyTail(inputDebug.recentShortcutOwnerStateKey));
|
||||
} else {
|
||||
detailLines.push_back("Recent shortcut: none");
|
||||
}
|
||||
if (!inputDebug.lastEventType.empty()) {
|
||||
const std::string eventPosition = inputDebug.lastEventType == "KeyDown" ||
|
||||
inputDebug.lastEventType == "KeyUp" ||
|
||||
inputDebug.lastEventType == "Character" ||
|
||||
inputDebug.lastEventType == "FocusGained" ||
|
||||
inputDebug.lastEventType == "FocusLost"
|
||||
? std::string()
|
||||
: " at " + FormatPoint(inputDebug.pointerPosition);
|
||||
detailLines.push_back(
|
||||
"Last input: " +
|
||||
inputDebug.lastEventType +
|
||||
eventPosition);
|
||||
detailLines.push_back(
|
||||
"Route: " +
|
||||
inputDebug.lastTargetKind +
|
||||
" -> " +
|
||||
ExtractStateKeyTail(inputDebug.lastTargetStateKey));
|
||||
if (!inputDebug.lastShortcutCommandId.empty()) {
|
||||
detailLines.push_back(
|
||||
"Shortcut: " +
|
||||
inputDebug.lastShortcutScope +
|
||||
" -> " +
|
||||
inputDebug.lastShortcutCommandId);
|
||||
detailLines.push_back(
|
||||
std::string("Shortcut state: ") +
|
||||
(inputDebug.lastShortcutHandled
|
||||
? "handled"
|
||||
: (inputDebug.lastShortcutSuppressed ? "suppressed" : "observed")) +
|
||||
" @ " +
|
||||
ExtractStateKeyTail(inputDebug.lastShortcutOwnerStateKey));
|
||||
}
|
||||
detailLines.push_back(
|
||||
"Last event result: " +
|
||||
(inputDebug.lastResult.empty() ? std::string("n/a") : inputDebug.lastResult));
|
||||
}
|
||||
}
|
||||
|
||||
if (m_autoScreenshot.HasPendingCapture()) {
|
||||
detailLines.push_back("Shot pending...");
|
||||
} else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) {
|
||||
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureSummary(), 78u));
|
||||
} else {
|
||||
detailLines.push_back("Screenshots: F12 -> current scenario captures/");
|
||||
}
|
||||
|
||||
if (!m_runtimeError.empty()) {
|
||||
detailLines.push_back(TruncateText(m_runtimeError, 78u));
|
||||
} else if (!m_autoScreenshot.GetLastCaptureError().empty()) {
|
||||
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureError(), 78u));
|
||||
} else if (!authoredMode) {
|
||||
detailLines.push_back("No fallback sandbox is rendered in this host.");
|
||||
}
|
||||
|
||||
const float panelHeight = 38.0f + static_cast<float>(detailLines.size()) * 18.0f;
|
||||
const UIRect panelRect(width - panelWidth - 16.0f, height - panelHeight - 42.0f, panelWidth, panelHeight);
|
||||
|
||||
UIDrawList& overlay = drawData.EmplaceDrawList("Editor UI Validation Overlay");
|
||||
overlay.AddFilledRect(panelRect, kOverlayBgColor, 10.0f);
|
||||
overlay.AddRectOutline(panelRect, kOverlayBorderColor, 1.0f, 10.0f);
|
||||
overlay.AddFilledRect(
|
||||
UIRect(panelRect.x + 12.0f, panelRect.y + 14.0f, 8.0f, 8.0f),
|
||||
authoredMode ? kOverlaySuccess : kOverlayFallback,
|
||||
4.0f);
|
||||
overlay.AddText(
|
||||
UIPoint(panelRect.x + 28.0f, panelRect.y + 10.0f),
|
||||
m_runtimeStatus.empty() ? "Editor UI Validation" : m_runtimeStatus,
|
||||
kOverlayTextPrimary,
|
||||
14.0f);
|
||||
|
||||
float detailY = panelRect.y + 30.0f;
|
||||
for (std::size_t index = 0; index < detailLines.size(); ++index) {
|
||||
const bool lastLine = index + 1u == detailLines.size();
|
||||
overlay.AddText(
|
||||
UIPoint(panelRect.x + 28.0f, detailY),
|
||||
detailLines[index],
|
||||
lastLine && (!m_runtimeError.empty() || !m_autoScreenshot.GetLastCaptureError().empty())
|
||||
? kOverlayFallback
|
||||
: kOverlayTextMuted,
|
||||
12.0f);
|
||||
detailY += 18.0f;
|
||||
}
|
||||
}
|
||||
|
||||
std::filesystem::path Application::ResolveRepoRelativePath(const char* relativePath) {
|
||||
return (GetRepoRootPath() / relativePath).lexically_normal();
|
||||
}
|
||||
|
||||
LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
|
||||
if (message == WM_NCCREATE) {
|
||||
const auto* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
|
||||
auto* application = reinterpret_cast<Application*>(createStruct->lpCreateParams);
|
||||
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(application));
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
Application* application = GetApplicationFromWindow(hwnd);
|
||||
switch (message) {
|
||||
case WM_SIZE:
|
||||
if (application != nullptr && wParam != SIZE_MINIMIZED) {
|
||||
application->OnResize(static_cast<UINT>(LOWORD(lParam)), static_cast<UINT>(HIWORD(lParam)));
|
||||
}
|
||||
return 0;
|
||||
case WM_PAINT:
|
||||
if (application != nullptr) {
|
||||
PAINTSTRUCT paintStruct = {};
|
||||
BeginPaint(hwnd, &paintStruct);
|
||||
application->RenderFrame();
|
||||
EndPaint(hwnd, &paintStruct);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_MOUSEMOVE:
|
||||
if (application != nullptr) {
|
||||
if (!application->m_trackingMouseLeave) {
|
||||
TRACKMOUSEEVENT trackMouseEvent = {};
|
||||
trackMouseEvent.cbSize = sizeof(trackMouseEvent);
|
||||
trackMouseEvent.dwFlags = TME_LEAVE;
|
||||
trackMouseEvent.hwndTrack = hwnd;
|
||||
if (TrackMouseEvent(&trackMouseEvent)) {
|
||||
application->m_trackingMouseLeave = true;
|
||||
}
|
||||
}
|
||||
application->QueuePointerEvent(UIInputEventType::PointerMove, UIPointerButton::None, wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_MOUSELEAVE:
|
||||
if (application != nullptr) {
|
||||
application->m_trackingMouseLeave = false;
|
||||
application->QueuePointerLeaveEvent();
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_LBUTTONDOWN:
|
||||
if (application != nullptr) {
|
||||
SetFocus(hwnd);
|
||||
SetCapture(hwnd);
|
||||
application->QueuePointerEvent(UIInputEventType::PointerButtonDown, UIPointerButton::Left, wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_LBUTTONUP:
|
||||
if (application != nullptr) {
|
||||
if (GetCapture() == hwnd) {
|
||||
ReleaseCapture();
|
||||
}
|
||||
application->QueuePointerEvent(UIInputEventType::PointerButtonUp, UIPointerButton::Left, wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_MOUSEWHEEL:
|
||||
if (application != nullptr) {
|
||||
application->QueuePointerWheelEvent(GET_WHEEL_DELTA_WPARAM(wParam), wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_SETFOCUS:
|
||||
if (application != nullptr) {
|
||||
application->m_inputModifierTracker.SyncFromSystemState();
|
||||
application->QueueWindowFocusEvent(UIInputEventType::FocusGained);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_KILLFOCUS:
|
||||
if (application != nullptr) {
|
||||
application->m_inputModifierTracker.Reset();
|
||||
application->QueueWindowFocusEvent(UIInputEventType::FocusLost);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_KEYDOWN:
|
||||
case WM_SYSKEYDOWN:
|
||||
if (application != nullptr) {
|
||||
if (wParam == VK_F12) {
|
||||
application->m_autoScreenshot.RequestCapture("manual_f12");
|
||||
}
|
||||
application->QueueKeyEvent(UIInputEventType::KeyDown, wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_KEYUP:
|
||||
case WM_SYSKEYUP:
|
||||
if (application != nullptr) {
|
||||
application->QueueKeyEvent(UIInputEventType::KeyUp, wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_CHAR:
|
||||
if (application != nullptr) {
|
||||
application->QueueCharacterEvent(wParam, lParam);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
case WM_ERASEBKGND:
|
||||
return 1;
|
||||
case WM_DESTROY:
|
||||
if (application != nullptr) {
|
||||
application->m_hwnd = nullptr;
|
||||
}
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return DefWindowProcW(hwnd, message, wParam, lParam);
|
||||
}
|
||||
|
||||
int RunEditorUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId) {
|
||||
Application application(std::move(requestedScenarioId));
|
||||
return application.Run(hInstance, nCmdShow);
|
||||
}
|
||||
|
||||
} // namespace XCEngine::Tests::EditorUI
|
||||
83
tests/UI/Editor/integration/shared/src/Application.h
Normal file
83
tests/UI/Editor/integration/shared/src/Application.h
Normal file
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
|
||||
#include "EditorValidationScenario.h"
|
||||
#include <XCNewEditor/Host/AutoScreenshot.h>
|
||||
#include <XCNewEditor/Host/InputModifierTracker.h>
|
||||
#include <XCNewEditor/Host/NativeRenderer.h>
|
||||
|
||||
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
|
||||
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
|
||||
|
||||
#include <windows.h>
|
||||
#include <windowsx.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace XCEngine::Tests::EditorUI {
|
||||
|
||||
class Application {
|
||||
public:
|
||||
explicit Application(std::string requestedScenarioId = {});
|
||||
|
||||
int Run(HINSTANCE hInstance, int nCmdShow);
|
||||
|
||||
private:
|
||||
struct TrackedFileState {
|
||||
std::filesystem::path path = {};
|
||||
std::filesystem::file_time_type writeTime = {};
|
||||
bool exists = false;
|
||||
};
|
||||
|
||||
static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
|
||||
|
||||
bool Initialize(HINSTANCE hInstance, int nCmdShow);
|
||||
void Shutdown();
|
||||
void RenderFrame();
|
||||
void OnResize(UINT width, UINT height);
|
||||
void QueuePointerEvent(::XCEngine::UI::UIInputEventType type, ::XCEngine::UI::UIPointerButton button, WPARAM wParam, LPARAM lParam);
|
||||
void QueuePointerLeaveEvent();
|
||||
void QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam);
|
||||
void QueueKeyEvent(::XCEngine::UI::UIInputEventType type, WPARAM wParam, LPARAM lParam);
|
||||
void QueueCharacterEvent(WPARAM wParam, LPARAM lParam);
|
||||
void QueueWindowFocusEvent(::XCEngine::UI::UIInputEventType type);
|
||||
bool LoadStructuredScreen(const char* triggerReason);
|
||||
void RefreshStructuredScreen();
|
||||
void RebuildTrackedFileStates();
|
||||
bool DetectTrackedFileChange() const;
|
||||
void AppendRuntimeOverlay(::XCEngine::UI::UIDrawData& drawData, float width, float height) const;
|
||||
static std::filesystem::path ResolveRepoRelativePath(const char* relativePath);
|
||||
|
||||
HWND m_hwnd = nullptr;
|
||||
HINSTANCE m_hInstance = nullptr;
|
||||
ATOM m_windowClassAtom = 0;
|
||||
::XCEngine::XCUI::Host::NativeRenderer m_renderer;
|
||||
::XCEngine::XCUI::Host::AutoScreenshotController m_autoScreenshot;
|
||||
::XCEngine::UI::Runtime::UIDocumentScreenHost m_documentHost;
|
||||
::XCEngine::UI::Runtime::UIScreenPlayer m_screenPlayer;
|
||||
::XCEngine::UI::Runtime::UIScreenAsset m_screenAsset = {};
|
||||
const EditorValidationScenario* m_activeScenario = nullptr;
|
||||
std::string m_requestedScenarioId = {};
|
||||
std::vector<TrackedFileState> m_trackedFiles = {};
|
||||
std::chrono::steady_clock::time_point m_startTime = {};
|
||||
std::chrono::steady_clock::time_point m_lastFrameTime = {};
|
||||
std::chrono::steady_clock::time_point m_lastReloadPollTime = {};
|
||||
std::uint64_t m_frameIndex = 0;
|
||||
std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {};
|
||||
::XCEngine::XCUI::Host::InputModifierTracker m_inputModifierTracker = {};
|
||||
bool m_trackingMouseLeave = false;
|
||||
bool m_useStructuredScreen = false;
|
||||
std::string m_runtimeStatus = {};
|
||||
std::string m_runtimeError = {};
|
||||
};
|
||||
|
||||
int RunEditorUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId = {});
|
||||
|
||||
} // namespace XCEngine::Tests::EditorUI
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "EditorValidationScenario.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
|
||||
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
|
||||
#endif
|
||||
|
||||
namespace XCEngine::Tests::EditorUI {
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
fs::path RepoRootPath() {
|
||||
std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT;
|
||||
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
|
||||
root = root.substr(1u, root.size() - 2u);
|
||||
}
|
||||
return fs::path(root).lexically_normal();
|
||||
}
|
||||
|
||||
fs::path RepoRelative(const char* relativePath) {
|
||||
return (RepoRootPath() / relativePath).lexically_normal();
|
||||
}
|
||||
|
||||
const std::array<EditorValidationScenario, 4>& GetEditorValidationScenarios() {
|
||||
static const std::array<EditorValidationScenario, 4> scenarios = { {
|
||||
{
|
||||
"editor.input.keyboard_focus",
|
||||
UIValidationDomain::Editor,
|
||||
"input",
|
||||
"Editor Input | Keyboard Focus",
|
||||
RepoRelative("tests/UI/Editor/integration/input/keyboard_focus/View.xcui"),
|
||||
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
|
||||
RepoRelative("tests/UI/Editor/integration/input/keyboard_focus/captures")
|
||||
},
|
||||
{
|
||||
"editor.input.pointer_states",
|
||||
UIValidationDomain::Editor,
|
||||
"input",
|
||||
"Editor Input | Pointer States",
|
||||
RepoRelative("tests/UI/Editor/integration/input/pointer_states/View.xcui"),
|
||||
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
|
||||
RepoRelative("tests/UI/Editor/integration/input/pointer_states/captures")
|
||||
},
|
||||
{
|
||||
"editor.input.shortcut_scope",
|
||||
UIValidationDomain::Editor,
|
||||
"input",
|
||||
"Editor Input | Shortcut Scope",
|
||||
RepoRelative("tests/UI/Editor/integration/input/shortcut_scope/View.xcui"),
|
||||
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
|
||||
RepoRelative("tests/UI/Editor/integration/input/shortcut_scope/captures")
|
||||
},
|
||||
{
|
||||
"editor.layout.splitter_resize",
|
||||
UIValidationDomain::Editor,
|
||||
"layout",
|
||||
"Editor Layout | Splitter Resize",
|
||||
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/View.xcui"),
|
||||
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
|
||||
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/captures")
|
||||
}
|
||||
} };
|
||||
return scenarios;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const EditorValidationScenario& GetDefaultEditorValidationScenario() {
|
||||
return GetEditorValidationScenarios().front();
|
||||
}
|
||||
|
||||
const EditorValidationScenario* FindEditorValidationScenario(std::string_view id) {
|
||||
for (const EditorValidationScenario& scenario : GetEditorValidationScenarios()) {
|
||||
if (scenario.id == id) {
|
||||
return &scenario;
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace XCEngine::Tests::EditorUI
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace XCEngine::Tests::EditorUI {
|
||||
|
||||
enum class UIValidationDomain : unsigned char {
|
||||
Editor = 0,
|
||||
Runtime
|
||||
};
|
||||
|
||||
struct EditorValidationScenario {
|
||||
std::string id = {};
|
||||
UIValidationDomain domain = UIValidationDomain::Editor;
|
||||
std::string categoryId = {};
|
||||
std::string displayName = {};
|
||||
std::filesystem::path documentPath = {};
|
||||
std::filesystem::path themePath = {};
|
||||
std::filesystem::path captureRootPath = {};
|
||||
};
|
||||
|
||||
const EditorValidationScenario& GetDefaultEditorValidationScenario();
|
||||
const EditorValidationScenario* FindEditorValidationScenario(std::string_view id);
|
||||
|
||||
} // namespace XCEngine::Tests::EditorUI
|
||||
@@ -0,0 +1,32 @@
|
||||
<Theme name="EditorValidationTheme">
|
||||
<Tokens>
|
||||
<Color name="color.bg.workspace" value="#1C1C1C" />
|
||||
<Color name="color.bg.panel" value="#292929" />
|
||||
<Color name="color.bg.accent" value="#3A3A3A" />
|
||||
<Color name="color.bg.selection" value="#4A4A4A" />
|
||||
<Color name="color.text.primary" value="#EEEEEE" />
|
||||
<Color name="color.text.muted" value="#B0B0B0" />
|
||||
<Spacing name="space.panel" value="12" />
|
||||
<Spacing name="space.shell" value="18" />
|
||||
<Radius name="radius.panel" value="10" />
|
||||
<Radius name="radius.control" value="8" />
|
||||
</Tokens>
|
||||
|
||||
<Widgets>
|
||||
<Widget type="View" style="EditorWorkspace">
|
||||
<Property name="background" value="color.bg.workspace" />
|
||||
<Property name="padding" value="space.shell" />
|
||||
</Widget>
|
||||
|
||||
<Widget type="Card" style="EditorPanel">
|
||||
<Property name="background" value="color.bg.panel" />
|
||||
<Property name="radius" value="radius.panel" />
|
||||
<Property name="padding" value="space.panel" />
|
||||
</Widget>
|
||||
|
||||
<Widget type="Button" style="EditorChip">
|
||||
<Property name="background" value="color.bg.selection" />
|
||||
<Property name="radius" value="radius.control" />
|
||||
</Widget>
|
||||
</Widgets>
|
||||
</Theme>
|
||||
35
tests/UI/Editor/unit/CMakeLists.txt
Normal file
35
tests/UI/Editor/unit/CMakeLists.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
set(EDITOR_UI_UNIT_TEST_SOURCES
|
||||
test_input_modifier_tracker.cpp
|
||||
test_editor_validation_registry.cpp
|
||||
test_structured_editor_shell.cpp
|
||||
# Migration bridge: editor-facing XCUI primitive tests still reuse the
|
||||
# legacy source location until they are relocated under tests/UI/Editor/unit.
|
||||
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_editor_collection_primitives.cpp
|
||||
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_editor_panel_chrome.cpp
|
||||
)
|
||||
|
||||
add_executable(editor_ui_tests ${EDITOR_UI_UNIT_TEST_SOURCES})
|
||||
|
||||
target_link_libraries(editor_ui_tests
|
||||
PRIVATE
|
||||
editor_ui_validation_registry
|
||||
XCNewEditorLib
|
||||
GTest::gtest_main
|
||||
)
|
||||
|
||||
target_include_directories(editor_ui_tests
|
||||
PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/new_editor/include
|
||||
${CMAKE_SOURCE_DIR}/new_editor/src
|
||||
${CMAKE_SOURCE_DIR}/engine/include
|
||||
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(editor_ui_tests PRIVATE /utf-8 /FS)
|
||||
set_property(TARGET editor_ui_tests PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
|
||||
endif()
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(editor_ui_tests)
|
||||
48
tests/UI/Editor/unit/test_editor_validation_registry.cpp
Normal file
48
tests/UI/Editor/unit/test_editor_validation_registry.cpp
Normal file
@@ -0,0 +1,48 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "EditorValidationScenario.h"
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::Tests::EditorUI::FindEditorValidationScenario;
|
||||
using XCEngine::Tests::EditorUI::GetDefaultEditorValidationScenario;
|
||||
using XCEngine::Tests::EditorUI::UIValidationDomain;
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExistingResources) {
|
||||
const auto* pointerScenario = FindEditorValidationScenario("editor.input.pointer_states");
|
||||
const auto* keyboardScenario = FindEditorValidationScenario("editor.input.keyboard_focus");
|
||||
const auto* shortcutScenario = FindEditorValidationScenario("editor.input.shortcut_scope");
|
||||
const auto* splitterScenario = FindEditorValidationScenario("editor.layout.splitter_resize");
|
||||
|
||||
ASSERT_NE(pointerScenario, nullptr);
|
||||
ASSERT_NE(keyboardScenario, nullptr);
|
||||
ASSERT_NE(shortcutScenario, nullptr);
|
||||
ASSERT_NE(splitterScenario, nullptr);
|
||||
EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Editor);
|
||||
EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Editor);
|
||||
EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Editor);
|
||||
EXPECT_EQ(splitterScenario->domain, UIValidationDomain::Editor);
|
||||
EXPECT_EQ(pointerScenario->categoryId, "input");
|
||||
EXPECT_EQ(keyboardScenario->categoryId, "input");
|
||||
EXPECT_EQ(shortcutScenario->categoryId, "input");
|
||||
EXPECT_EQ(splitterScenario->categoryId, "layout");
|
||||
EXPECT_TRUE(std::filesystem::exists(pointerScenario->documentPath));
|
||||
EXPECT_TRUE(std::filesystem::exists(pointerScenario->themePath));
|
||||
EXPECT_TRUE(std::filesystem::exists(keyboardScenario->documentPath));
|
||||
EXPECT_TRUE(std::filesystem::exists(keyboardScenario->themePath));
|
||||
EXPECT_TRUE(std::filesystem::exists(shortcutScenario->documentPath));
|
||||
EXPECT_TRUE(std::filesystem::exists(shortcutScenario->themePath));
|
||||
EXPECT_TRUE(std::filesystem::exists(splitterScenario->documentPath));
|
||||
EXPECT_TRUE(std::filesystem::exists(splitterScenario->themePath));
|
||||
}
|
||||
|
||||
TEST(EditorValidationRegistryTest, DefaultScenarioPointsToKeyboardFocusBatch) {
|
||||
const auto& scenario = GetDefaultEditorValidationScenario();
|
||||
EXPECT_EQ(scenario.id, "editor.input.keyboard_focus");
|
||||
EXPECT_EQ(scenario.domain, UIValidationDomain::Editor);
|
||||
EXPECT_TRUE(std::filesystem::exists(scenario.documentPath));
|
||||
}
|
||||
90
tests/UI/Editor/unit/test_input_modifier_tracker.cpp
Normal file
90
tests/UI/Editor/unit/test_input_modifier_tracker.cpp
Normal file
@@ -0,0 +1,90 @@
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <XCNewEditor/Host/InputModifierTracker.h>
|
||||
|
||||
#include <XCEngine/UI/Types.h>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::XCUI::Host::InputModifierTracker;
|
||||
using XCEngine::UI::UIInputEventType;
|
||||
|
||||
TEST(InputModifierTrackerTest, ControlStatePersistsAcrossChordKeyDownAndClearsOnKeyUp) {
|
||||
InputModifierTracker tracker = {};
|
||||
|
||||
const auto ctrlDown = tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
VK_CONTROL,
|
||||
0x001D0001);
|
||||
EXPECT_TRUE(ctrlDown.control);
|
||||
EXPECT_FALSE(ctrlDown.shift);
|
||||
EXPECT_FALSE(ctrlDown.alt);
|
||||
|
||||
const auto chordKeyDown = tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
'P',
|
||||
0x00190001);
|
||||
EXPECT_TRUE(chordKeyDown.control);
|
||||
|
||||
const auto ctrlUp = tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyUp,
|
||||
VK_CONTROL,
|
||||
static_cast<LPARAM>(0xC01D0001u));
|
||||
EXPECT_FALSE(ctrlUp.control);
|
||||
|
||||
const auto nextKeyDown = tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
'P',
|
||||
0x00190001);
|
||||
EXPECT_FALSE(nextKeyDown.control);
|
||||
}
|
||||
|
||||
TEST(InputModifierTrackerTest, PointerModifiersMergeMouseFlagsWithTrackedKeyboardState) {
|
||||
InputModifierTracker tracker = {};
|
||||
tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
VK_MENU,
|
||||
0x00380001);
|
||||
|
||||
const auto modifiers = tracker.BuildPointerModifiers(MK_SHIFT);
|
||||
EXPECT_TRUE(modifiers.shift);
|
||||
EXPECT_TRUE(modifiers.alt);
|
||||
EXPECT_FALSE(modifiers.control);
|
||||
EXPECT_FALSE(modifiers.super);
|
||||
}
|
||||
|
||||
TEST(InputModifierTrackerTest, RightControlIsTrackedIndependentlyFromLeftControl) {
|
||||
InputModifierTracker tracker = {};
|
||||
|
||||
tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
VK_CONTROL,
|
||||
static_cast<LPARAM>(0x011D0001u));
|
||||
EXPECT_TRUE(tracker.GetCurrentModifiers().control);
|
||||
|
||||
tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyDown,
|
||||
VK_CONTROL,
|
||||
0x001D0001);
|
||||
EXPECT_TRUE(tracker.GetCurrentModifiers().control);
|
||||
|
||||
tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyUp,
|
||||
VK_CONTROL,
|
||||
static_cast<LPARAM>(0xC11D0001u));
|
||||
EXPECT_TRUE(tracker.GetCurrentModifiers().control);
|
||||
|
||||
tracker.ApplyKeyMessage(
|
||||
UIInputEventType::KeyUp,
|
||||
VK_CONTROL,
|
||||
static_cast<LPARAM>(0xC01D0001u));
|
||||
EXPECT_FALSE(tracker.GetCurrentModifiers().control);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
93
tests/UI/Editor/unit/test_structured_editor_shell.cpp
Normal file
93
tests/UI/Editor/unit/test_structured_editor_shell.cpp
Normal file
@@ -0,0 +1,93 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "editor/EditorShellAsset.h"
|
||||
|
||||
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
|
||||
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifndef XCNEWEDITOR_REPO_ROOT
|
||||
#define XCNEWEDITOR_REPO_ROOT "."
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
using XCEngine::NewEditor::BuildDefaultEditorShellAsset;
|
||||
using XCEngine::UI::UIDrawCommand;
|
||||
using XCEngine::UI::UIDrawCommandType;
|
||||
using XCEngine::UI::UIDrawData;
|
||||
using XCEngine::UI::Runtime::UIScreenAsset;
|
||||
using XCEngine::UI::Runtime::UIScreenFrameInput;
|
||||
using XCEngine::UI::Runtime::UIScreenPlayer;
|
||||
using XCEngine::UI::Runtime::UIDocumentScreenHost;
|
||||
|
||||
std::filesystem::path RepoRootPath() {
|
||||
std::string root = XCNEWEDITOR_REPO_ROOT;
|
||||
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
|
||||
root = root.substr(1u, root.size() - 2u);
|
||||
}
|
||||
return std::filesystem::path(root).lexically_normal();
|
||||
}
|
||||
|
||||
bool DrawDataContainsText(const UIDrawData& drawData, const std::string& text) {
|
||||
for (const auto& drawList : drawData.GetDrawLists()) {
|
||||
for (const UIDrawCommand& command : drawList.GetCommands()) {
|
||||
if (command.type == UIDrawCommandType::Text && command.text == text) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ContainsPathWithFilename(
|
||||
const std::vector<std::string>& paths,
|
||||
const char* expectedFileName) {
|
||||
for (const std::string& path : paths) {
|
||||
if (std::filesystem::path(path).filename() == expectedFileName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(EditorUIStructuredShellTest, AuthoredEditorShellLoadsFromRepositoryResources) {
|
||||
const auto shell = BuildDefaultEditorShellAsset(RepoRootPath());
|
||||
|
||||
ASSERT_TRUE(std::filesystem::exists(shell.documentPath));
|
||||
ASSERT_TRUE(std::filesystem::exists(shell.themePath));
|
||||
|
||||
UIScreenAsset asset = {};
|
||||
asset.screenId = shell.screenId;
|
||||
asset.documentPath = shell.documentPath.string();
|
||||
asset.themePath = shell.themePath.string();
|
||||
|
||||
UIDocumentScreenHost host = {};
|
||||
UIScreenPlayer player(host);
|
||||
|
||||
ASSERT_TRUE(player.Load(asset)) << player.GetLastError();
|
||||
ASSERT_NE(player.GetDocument(), nullptr);
|
||||
EXPECT_TRUE(player.GetDocument()->hasThemeDocument);
|
||||
EXPECT_TRUE(ContainsPathWithFilename(player.GetDocument()->dependencies, "editor_shell.xctheme"));
|
||||
|
||||
UIScreenFrameInput input = {};
|
||||
input.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 1440.0f, 900.0f);
|
||||
input.frameIndex = 1u;
|
||||
input.focused = true;
|
||||
|
||||
const auto& frame = player.Update(input);
|
||||
EXPECT_TRUE(frame.stats.documentLoaded);
|
||||
EXPECT_GE(frame.stats.nodeCount, 2u);
|
||||
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "XCUI Editor Layer"));
|
||||
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Left Pane Host"));
|
||||
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Primary Workspace Host"));
|
||||
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Right Pane Host"));
|
||||
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Bottom Pane Host"));
|
||||
}
|
||||
Reference in New Issue
Block a user