chore: checkpoint current workspace changes

This commit is contained in:
2026-04-11 22:14:02 +08:00
parent 3e55f8c204
commit 8848cfd958
227 changed files with 34027 additions and 6711 deletions

View File

@@ -15,7 +15,8 @@ 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.
- Screenshots are written only under the active CMake build directory.
- The output root is the executable directory: `.../Debug/captures/<scenario>/`.
Build:

View File

@@ -1,11 +1,18 @@
add_subdirectory(drag_drop_basic)
add_subdirectory(keyboard_focus)
add_subdirectory(popup_menu_overlay)
add_subdirectory(pointer_states)
add_subdirectory(scroll_view)
add_subdirectory(shortcut_scope)
add_custom_target(core_ui_drag_drop_contract_validation
DEPENDS
core_ui_input_drag_drop_basic_validation
)
add_custom_target(core_ui_input_integration_tests
DEPENDS
core_ui_input_drag_drop_basic_validation
core_ui_input_keyboard_focus_validation
core_ui_input_popup_menu_overlay_validation
core_ui_input_pointer_states_validation

View File

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

View File

@@ -0,0 +1,19 @@
<View
name="CoreInputDragDropContract"
theme="../../shared/themes/core_validation.xctheme">
<Column padding="24" gap="16">
<Card
title="测试内容Core Drag / Drop Contract"
subtitle="只验证 Core 层拖拽原语本身激活阈值、target accept/reject、release 完成、以及 Escape / focus lost 取消。"
tone="accent"
height="206">
<Column gap="8">
<Text text="1. 按住左侧任一 source 后不要立刻松开;只有拖过激活阈值后才会进入 active。" />
<Text text="2. 将 Texture Asset 拖到 Project Browser应显示 accept预览操作应解析为 Copy。" />
<Text text="3. 将 Texture Asset 拖到 Hierarchy Parent应显示 reject此时松开只会取消不会完成 drop。" />
<Text text="4. 将 Scene Entity 拖到 Hierarchy Parent应显示 accept松开后应完成 Move。" />
<Text text="5. active 期间按 Esc或切走窗口触发 focus lost应立即取消当前 drag。" />
</Column>
</Card>
</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.input.drag_drop_basic");
}

View File

@@ -1,4 +1,5 @@
file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH)
file(TO_CMAKE_PATH "${CMAKE_BINARY_DIR}" XCENGINE_CORE_UI_TESTS_BUILD_ROOT_PATH)
add_library(core_ui_validation_registry STATIC
src/CoreValidationScenario.cpp
@@ -29,6 +30,7 @@ target_link_libraries(core_ui_validation_registry
add_library(core_ui_integration_host STATIC
src/AutoScreenshot.cpp
src/Application.cpp
src/DragDropValidationScene.cpp
src/NativeRenderer.cpp
src/PopupMenuOverlayValidationScene.cpp
)
@@ -44,6 +46,7 @@ target_compile_definitions(core_ui_integration_host
UNICODE
_UNICODE
XCENGINE_CORE_UI_TESTS_REPO_ROOT="${XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH}"
XCENGINE_CORE_UI_TESTS_BUILD_ROOT="${XCENGINE_CORE_UI_TESTS_BUILD_ROOT_PATH}"
)
if(MSVC)

View File

@@ -15,6 +15,10 @@
#define XCENGINE_CORE_UI_TESTS_REPO_ROOT "."
#endif
#ifndef XCENGINE_CORE_UI_TESTS_BUILD_ROOT
#define XCENGINE_CORE_UI_TESTS_BUILD_ROOT "."
#endif
namespace XCEngine::Tests::CoreUI {
namespace {
@@ -53,6 +57,47 @@ std::filesystem::path GetRepoRootPath() {
return std::filesystem::path(root).lexically_normal();
}
std::filesystem::path GetBuildRootPath() {
std::string root = XCENGINE_CORE_UI_TESTS_BUILD_ROOT;
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
root = root.substr(1u, root.size() - 2u);
}
return std::filesystem::path(root).lexically_normal();
}
bool TryMakeRepoRelativePath(
const std::filesystem::path& absolutePath,
std::filesystem::path& outRelativePath) {
std::error_code errorCode = {};
outRelativePath = std::filesystem::relative(
absolutePath,
GetRepoRootPath(),
errorCode);
if (errorCode || outRelativePath.empty()) {
return false;
}
for (const auto& part : outRelativePath) {
if (part == "..") {
return false;
}
}
return true;
}
std::filesystem::path ResolveCaptureOutputRoot(
const std::filesystem::path& sourceCaptureRoot) {
const std::filesystem::path normalizedSourcePath =
sourceCaptureRoot.lexically_normal();
std::filesystem::path relativePath = {};
if (TryMakeRepoRelativePath(normalizedSourcePath, relativePath)) {
return (GetBuildRootPath() / relativePath).lexically_normal();
}
return (GetBuildRootPath() / "ui_test_captures" / normalizedSourcePath.filename())
.lexically_normal();
}
std::string TruncateText(const std::string& text, std::size_t maxLength) {
if (text.size() <= maxLength) {
return text;
@@ -250,7 +295,8 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) {
if (initialScenario == nullptr) {
initialScenario = &GetDefaultCoreValidationScenario();
}
m_autoScreenshot.Initialize(initialScenario->captureRootPath);
m_autoScreenshot.Initialize(
ResolveCaptureOutputRoot(initialScenario->captureRootPath));
LoadStructuredScreen("startup");
return true;
}
@@ -306,6 +352,10 @@ void Application::RenderFrame() {
m_activeScenario->id == PopupMenuOverlayValidationScene::ScenarioId) {
m_popupMenuOverlayScene.Update(frameEvents, viewportRect, windowFocused);
}
if (m_activeScenario != nullptr &&
m_activeScenario->id == DragDropValidationScene::ScenarioId) {
m_dragDropValidationScene.Update(frameEvents, viewportRect, windowFocused);
}
if (m_useStructuredScreen && m_screenPlayer.IsLoaded()) {
UIScreenFrameInput input = {};
@@ -330,6 +380,10 @@ void Application::RenderFrame() {
m_activeScenario->id == PopupMenuOverlayValidationScene::ScenarioId) {
m_popupMenuOverlayScene.AppendDrawData(drawData, viewportRect);
}
if (m_activeScenario != nullptr &&
m_activeScenario->id == DragDropValidationScene::ScenarioId) {
m_dragDropValidationScene.AppendDrawData(drawData, viewportRect);
}
if (drawData.Empty()) {
m_runtimeStatus = "Core UI Validation | Load Error";
@@ -447,6 +501,7 @@ bool Application::LoadStructuredScreen(const char* triggerReason) {
: (scenarioLoadWarning.empty()
? m_screenPlayer.GetLastError()
: scenarioLoadWarning + " | " + m_screenPlayer.GetLastError());
m_dragDropValidationScene.Reset();
m_popupMenuOverlayScene.Reset();
RebuildTrackedFileStates();
return loaded;
@@ -641,7 +696,11 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float
} 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(
"Screenshots: F12 -> " +
TruncateText(
m_autoScreenshot.GetLatestCapturePath().parent_path().string(),
60u));
}
if (!m_runtimeError.empty()) {

View File

@@ -6,6 +6,7 @@
#include "AutoScreenshot.h"
#include "CoreValidationScenario.h"
#include "DragDropValidationScene.h"
#include "InputModifierTracker.h"
#include "NativeRenderer.h"
#include "PopupMenuOverlayValidationScene.h"
@@ -73,6 +74,7 @@ private:
std::uint64_t m_frameIndex = 0;
std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {};
Host::InputModifierTracker m_inputModifierTracker = {};
DragDropValidationScene m_dragDropValidationScene = {};
PopupMenuOverlayValidationScene m_popupMenuOverlayScene = {};
bool m_trackingMouseLeave = false;
bool m_useStructuredScreen = false;

View File

@@ -4,21 +4,80 @@
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <cstdio>
#include <sstream>
#include <system_error>
#include <vector>
#include <windows.h>
namespace XCEngine::Tests::CoreUI::Host {
namespace {
bool IsAutoCaptureOnStartupEnabled() {
const char* value = std::getenv("XCUI_AUTO_CAPTURE_ON_STARTUP");
if (value == nullptr || value[0] == '\0') {
return false;
}
std::string normalized = value;
for (char& character : normalized) {
character = static_cast<char>(std::tolower(static_cast<unsigned char>(character)));
}
return normalized != "0" &&
normalized != "false" &&
normalized != "off" &&
normalized != "no";
}
std::filesystem::path GetExecutableDirectory() {
std::vector<wchar_t> buffer(MAX_PATH);
while (true) {
const DWORD copied = ::GetModuleFileNameW(
nullptr,
buffer.data(),
static_cast<DWORD>(buffer.size()));
if (copied == 0u) {
return std::filesystem::current_path().lexically_normal();
}
if (copied < buffer.size() - 1u) {
return std::filesystem::path(std::wstring(buffer.data(), copied))
.parent_path()
.lexically_normal();
}
buffer.resize(buffer.size() * 2u);
}
}
std::filesystem::path ResolveBuildCaptureRoot(const std::filesystem::path& requestedCaptureRoot) {
std::filesystem::path captureRoot = GetExecutableDirectory() / "captures";
const std::filesystem::path scenarioPath = requestedCaptureRoot.parent_path().filename();
if (!scenarioPath.empty() && scenarioPath != "captures") {
captureRoot /= scenarioPath;
}
return captureRoot.lexically_normal();
}
} // namespace
void AutoScreenshotController::Initialize(const std::filesystem::path& captureRoot) {
m_captureRoot = captureRoot.lexically_normal();
m_captureRoot = ResolveBuildCaptureRoot(captureRoot);
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_lastCaptureSummary = "Output: " + m_captureRoot.string();
m_lastCaptureError.clear();
if (IsAutoCaptureOnStartupEnabled()) {
RequestCapture("startup");
}
}
void AutoScreenshotController::Shutdown() {

View File

@@ -24,8 +24,17 @@ fs::path RepoRelative(const char* relativePath) {
return (RepoRootPath() / relativePath).lexically_normal();
}
const std::array<CoreValidationScenario, 10>& GetCoreValidationScenarios() {
static const std::array<CoreValidationScenario, 10> scenarios = { {
const std::array<CoreValidationScenario, 11>& GetCoreValidationScenarios() {
static const std::array<CoreValidationScenario, 11> scenarios = { {
{
"core.input.drag_drop_basic",
UIValidationDomain::Core,
"input",
"Core Input | Drag Drop Contract",
RepoRelative("tests/UI/Core/integration/input/drag_drop_basic/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/input/drag_drop_basic/captures")
},
{
"core.input.keyboard_focus",
UIValidationDomain::Core,
@@ -123,6 +132,12 @@ const std::array<CoreValidationScenario, 10>& GetCoreValidationScenarios() {
} // namespace
const CoreValidationScenario& GetDefaultCoreValidationScenario() {
for (const CoreValidationScenario& scenario : GetCoreValidationScenarios()) {
if (scenario.id == "core.input.keyboard_focus") {
return scenario;
}
}
return GetCoreValidationScenarios().front();
}

View File

@@ -0,0 +1,504 @@
#include "DragDropValidationScene.h"
#include <XCEngine/Input/InputTypes.h>
#include <array>
#include <string>
#include <utility>
namespace XCEngine::Tests::CoreUI {
namespace {
using ::XCEngine::Input::KeyCode;
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::Widgets::BeginUIDragDrop;
using ::XCEngine::UI::Widgets::CancelUIDragDrop;
using ::XCEngine::UI::Widgets::EndUIDragDrop;
using ::XCEngine::UI::Widgets::HasResolvedUIDragDropTarget;
using ::XCEngine::UI::Widgets::IsUIDragDropInProgress;
using ::XCEngine::UI::Widgets::UIDragDropOperation;
using ::XCEngine::UI::Widgets::UIDragDropPayload;
using ::XCEngine::UI::Widgets::UIDragDropResult;
using ::XCEngine::UI::Widgets::UIDragDropSourceDescriptor;
using ::XCEngine::UI::Widgets::UIDragDropTargetDescriptor;
using ::XCEngine::UI::Widgets::UpdateUIDragDropPointer;
using ::XCEngine::UI::Widgets::UpdateUIDragDropTarget;
constexpr UIColor kLabPanelBg(0.12f, 0.12f, 0.12f, 1.0f);
constexpr UIColor kLabPanelBorder(0.24f, 0.24f, 0.24f, 1.0f);
constexpr UIColor kStatusBg(0.16f, 0.16f, 0.16f, 1.0f);
constexpr UIColor kCardBg(0.18f, 0.18f, 0.18f, 1.0f);
constexpr UIColor kCardHover(0.24f, 0.24f, 0.24f, 1.0f);
constexpr UIColor kCardBorder(0.32f, 0.32f, 0.32f, 1.0f);
constexpr UIColor kAccept(0.36f, 0.46f, 0.36f, 1.0f);
constexpr UIColor kAcceptBorder(0.56f, 0.72f, 0.56f, 1.0f);
constexpr UIColor kReject(0.34f, 0.22f, 0.22f, 1.0f);
constexpr UIColor kRejectBorder(0.72f, 0.38f, 0.38f, 1.0f);
constexpr UIColor kGhostBg(0.28f, 0.28f, 0.28f, 0.95f);
constexpr UIColor kGhostBorder(0.78f, 0.78f, 0.78f, 1.0f);
constexpr UIColor kTextPrimary(0.93f, 0.93f, 0.93f, 1.0f);
constexpr UIColor kTextMuted(0.72f, 0.72f, 0.72f, 1.0f);
constexpr UIColor kTextWeak(0.56f, 0.56f, 0.56f, 1.0f);
constexpr UIColor kTextSuccess(0.62f, 0.82f, 0.62f, 1.0f);
constexpr UIColor kTextDanger(0.84f, 0.48f, 0.48f, 1.0f);
constexpr std::uint64_t kTextureSourceOwnerId = 1001u;
constexpr std::uint64_t kEntitySourceOwnerId = 1002u;
constexpr std::uint64_t kProjectTargetOwnerId = 2001u;
constexpr std::uint64_t kHierarchyTargetOwnerId = 2002u;
std::string DescribeOperation(UIDragDropOperation operation) {
switch (operation) {
case UIDragDropOperation::Copy:
return "Copy";
case UIDragDropOperation::Move:
return "Move";
case UIDragDropOperation::Link:
return "Link";
default:
return "(none)";
}
}
void DrawPanel(
UIDrawList& drawList,
const UIRect& rect,
const UIColor& fillColor,
const UIColor& borderColor,
float rounding) {
drawList.AddFilledRect(rect, fillColor, rounding);
drawList.AddRectOutline(rect, borderColor, 1.0f, rounding);
}
void DrawCard(
UIDrawList& drawList,
const UIRect& rect,
std::string_view title,
std::string_view subtitle,
const UIColor& fillColor,
const UIColor& borderColor) {
DrawPanel(drawList, rect, fillColor, borderColor, 10.0f);
drawList.AddText(UIPoint(rect.x + 14.0f, rect.y + 12.0f), std::string(title), kTextPrimary, 15.0f);
drawList.AddText(UIPoint(rect.x + 14.0f, rect.y + 34.0f), std::string(subtitle), kTextMuted, 12.0f);
}
UIDragDropSourceDescriptor BuildTextureSource(const UIPoint& pointerPosition) {
UIDragDropSourceDescriptor source = {};
source.ownerId = kTextureSourceOwnerId;
source.sourceId = "project.texture";
source.pointerDownPosition = pointerPosition;
source.payload = UIDragDropPayload{ "asset.texture", "tex-001", "Checker.png" };
source.allowedOperations = UIDragDropOperation::Copy | UIDragDropOperation::Move;
source.activationDistance = 6.0f;
return source;
}
UIDragDropSourceDescriptor BuildEntitySource(const UIPoint& pointerPosition) {
UIDragDropSourceDescriptor source = {};
source.ownerId = kEntitySourceOwnerId;
source.sourceId = "hierarchy.entity";
source.pointerDownPosition = pointerPosition;
source.payload = UIDragDropPayload{ "scene.entity", "entity-hero", "HeroRoot" };
source.allowedOperations = UIDragDropOperation::Move;
source.activationDistance = 6.0f;
return source;
}
UIDragDropTargetDescriptor BuildProjectTarget() {
static constexpr std::array<std::string_view, 2> kAcceptedTypes = {
"asset.texture",
"asset.material"
};
UIDragDropTargetDescriptor target = {};
target.ownerId = kProjectTargetOwnerId;
target.targetId = "project.browser";
target.acceptedPayloadTypes = kAcceptedTypes;
target.acceptedOperations =
UIDragDropOperation::Copy |
UIDragDropOperation::Move;
target.preferredOperation = UIDragDropOperation::Copy;
return target;
}
UIDragDropTargetDescriptor BuildHierarchyTarget() {
static constexpr std::array<std::string_view, 1> kAcceptedTypes = {
"scene.entity"
};
UIDragDropTargetDescriptor target = {};
target.ownerId = kHierarchyTargetOwnerId;
target.targetId = "hierarchy.parent";
target.acceptedPayloadTypes = kAcceptedTypes;
target.acceptedOperations = UIDragDropOperation::Move;
target.preferredOperation = UIDragDropOperation::Move;
return target;
}
} // namespace
void DragDropValidationScene::Reset() {
m_dragState = {};
m_pointerPosition = {};
m_hasPointer = false;
m_resultText = "Result: Ready";
m_lastDropText = "(none)";
}
void DragDropValidationScene::Update(
const std::vector<UIInputEvent>& events,
const UIRect& viewportRect,
bool windowFocused) {
const Geometry geometry = BuildGeometry(viewportRect);
if (!windowFocused &&
IsUIDragDropInProgress(m_dragState)) {
HandleCancel("Result: focus lost, drag cancelled");
}
for (const UIInputEvent& event : events) {
switch (event.type) {
case UIInputEventType::PointerMove:
m_pointerPosition = event.position;
m_hasPointer = true;
HandlePointerMove(geometry, event.position);
break;
case UIInputEventType::PointerLeave:
m_hasPointer = false;
break;
case UIInputEventType::PointerButtonDown:
if (event.pointerButton == UIPointerButton::Left) {
m_pointerPosition = event.position;
m_hasPointer = true;
HandlePointerDown(geometry, event.position);
}
break;
case UIInputEventType::PointerButtonUp:
if (event.pointerButton == UIPointerButton::Left) {
m_pointerPosition = event.position;
m_hasPointer = true;
HandlePointerUp(geometry, event.position);
}
break;
case UIInputEventType::KeyDown:
if (event.keyCode == static_cast<std::int32_t>(KeyCode::Escape)) {
HandleCancel("Result: Escape cancelled current drag");
}
break;
case UIInputEventType::FocusLost:
HandleCancel("Result: focus lost, drag cancelled");
break;
default:
break;
}
}
}
void DragDropValidationScene::AppendDrawData(
UIDrawData& drawData,
const UIRect& viewportRect) const {
const Geometry geometry = BuildGeometry(viewportRect);
const bool hoverTexture = m_hasPointer && RectContains(geometry.textureSourceRect, m_pointerPosition);
const bool hoverEntity = m_hasPointer && RectContains(geometry.entitySourceRect, m_pointerPosition);
const bool hoverProject = m_hasPointer && RectContains(geometry.projectTargetRect, m_pointerPosition);
const bool hoverHierarchy = m_hasPointer && RectContains(geometry.hierarchyTargetRect, m_pointerPosition);
const bool dragProject =
m_dragState.active && m_dragState.targetOwnerId == kProjectTargetOwnerId;
const bool dragHierarchy =
m_dragState.active && m_dragState.targetOwnerId == kHierarchyTargetOwnerId;
const bool rejectProject =
m_dragState.active && hoverProject && !dragProject;
const bool rejectHierarchy =
m_dragState.active && hoverHierarchy && !dragHierarchy;
UIDrawList& drawList = drawData.EmplaceDrawList("Core Drag Drop Primitive Lab");
DrawPanel(drawList, geometry.labRect, kLabPanelBg, kLabPanelBorder, 12.0f);
DrawPanel(drawList, geometry.statusRect, kStatusBg, kCardBorder, 10.0f);
drawList.AddText(
UIPoint(geometry.statusRect.x + 14.0f, geometry.statusRect.y + 12.0f),
"测试内容Core Drag / Drop Contract",
kTextPrimary,
14.0f);
drawList.AddText(
UIPoint(geometry.statusRect.x + 14.0f, geometry.statusRect.y + 34.0f),
m_resultText,
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(geometry.statusRect.x + 14.0f, geometry.statusRect.y + 54.0f),
"Payload: " + (m_dragState.payload.label.empty() ? std::string("(none)") : m_dragState.payload.label),
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(geometry.statusRect.x + 280.0f, geometry.statusRect.y + 34.0f),
std::string("Armed: ") + (m_dragState.armed ? "true" : "false"),
m_dragState.armed ? kTextPrimary : kTextWeak,
12.0f);
drawList.AddText(
UIPoint(geometry.statusRect.x + 280.0f, geometry.statusRect.y + 54.0f),
std::string("Active: ") + (m_dragState.active ? "true" : "false"),
m_dragState.active ? kTextSuccess : kTextWeak,
12.0f);
drawList.AddText(
UIPoint(geometry.statusRect.x + 430.0f, geometry.statusRect.y + 34.0f),
"Hover Target: " +
(m_dragState.targetId.empty() ? std::string("(none)") : m_dragState.targetId),
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(geometry.statusRect.x + 430.0f, geometry.statusRect.y + 54.0f),
"Preview Op: " + DescribeOperation(m_dragState.previewOperation),
m_dragState.previewOperation == UIDragDropOperation::None ? kTextWeak : kTextSuccess,
12.0f);
drawList.AddText(
UIPoint(geometry.statusRect.x + 650.0f, geometry.statusRect.y + 34.0f),
"Last Drop: " + m_lastDropText,
kTextMuted,
12.0f);
DrawCard(
drawList,
geometry.sourcesRect,
"Sources",
"按下 source 后先保持,越过阈值才会进入 active。",
kCardBg,
kCardBorder);
DrawCard(
drawList,
geometry.targetsRect,
"Targets",
"右侧同时展示 accept / reject 与预览操作。",
kCardBg,
kCardBorder);
DrawCard(
drawList,
geometry.textureSourceRect,
"Texture Asset",
"type=asset.texture | Copy/Move",
hoverTexture ? kCardHover : kCardBg,
kCardBorder);
drawList.AddText(
UIPoint(geometry.textureSourceRect.x + 14.0f, geometry.textureSourceRect.y + 58.0f),
"Checker.png",
kTextPrimary,
13.0f);
DrawCard(
drawList,
geometry.entitySourceRect,
"Scene Entity",
"type=scene.entity | Move only",
hoverEntity ? kCardHover : kCardBg,
kCardBorder);
drawList.AddText(
UIPoint(geometry.entitySourceRect.x + 14.0f, geometry.entitySourceRect.y + 58.0f),
"HeroRoot",
kTextPrimary,
13.0f);
DrawCard(
drawList,
geometry.projectTargetRect,
"Project Browser",
"accepts asset.texture / asset.material | preferred Copy",
dragProject ? kAccept : (rejectProject ? kReject : (hoverProject ? kCardHover : kCardBg)),
dragProject ? kAcceptBorder : (rejectProject ? kRejectBorder : kCardBorder));
drawList.AddText(
UIPoint(geometry.projectTargetRect.x + 14.0f, geometry.projectTargetRect.y + 58.0f),
dragProject ? "Accepting current payload" : "拖入 texture 时应显示 Copy",
dragProject ? kTextSuccess : (rejectProject ? kTextDanger : kTextMuted),
12.0f);
DrawCard(
drawList,
geometry.hierarchyTargetRect,
"Hierarchy Parent",
"accepts scene.entity | preferred Move",
dragHierarchy ? kAccept : (rejectHierarchy ? kReject : (hoverHierarchy ? kCardHover : kCardBg)),
dragHierarchy ? kAcceptBorder : (rejectHierarchy ? kRejectBorder : kCardBorder));
drawList.AddText(
UIPoint(geometry.hierarchyTargetRect.x + 14.0f, geometry.hierarchyTargetRect.y + 58.0f),
dragHierarchy ? "Accepting current payload" : "拖入 entity 时应显示 Move",
dragHierarchy ? kTextSuccess : (rejectHierarchy ? kTextDanger : kTextMuted),
12.0f);
if (m_dragState.active) {
const UIRect ghostRect(
m_pointerPosition.x + 16.0f,
m_pointerPosition.y + 12.0f,
188.0f,
64.0f);
DrawPanel(drawList, ghostRect, kGhostBg, kGhostBorder, 8.0f);
drawList.AddText(
UIPoint(ghostRect.x + 12.0f, ghostRect.y + 12.0f),
m_dragState.payload.label.empty() ? std::string("(payload)") : m_dragState.payload.label,
kTextPrimary,
14.0f);
drawList.AddText(
UIPoint(ghostRect.x + 12.0f, ghostRect.y + 34.0f),
"type=" + m_dragState.payload.typeId + " | op=" + DescribeOperation(m_dragState.previewOperation),
kTextMuted,
12.0f);
}
}
DragDropValidationScene::Geometry DragDropValidationScene::BuildGeometry(
const UIRect& viewportRect) const {
Geometry geometry = {};
const float availableWidth = (std::max)(720.0f, viewportRect.width - 48.0f);
const float availableHeight = (std::max)(360.0f, viewportRect.height - 256.0f);
geometry.labRect = UIRect(
24.0f,
220.0f,
(std::min)(980.0f, availableWidth),
(std::min)(460.0f, availableHeight));
geometry.statusRect = UIRect(
geometry.labRect.x + 20.0f,
geometry.labRect.y + 18.0f,
geometry.labRect.width - 40.0f,
84.0f);
geometry.sourcesRect = UIRect(
geometry.labRect.x + 20.0f,
geometry.statusRect.y + geometry.statusRect.height + 18.0f,
280.0f,
geometry.labRect.height - 140.0f);
geometry.targetsRect = UIRect(
geometry.sourcesRect.x + geometry.sourcesRect.width + 18.0f,
geometry.sourcesRect.y,
geometry.labRect.width - 338.0f,
geometry.sourcesRect.height);
geometry.textureSourceRect = UIRect(
geometry.sourcesRect.x + 14.0f,
geometry.sourcesRect.y + 54.0f,
geometry.sourcesRect.width - 28.0f,
96.0f);
geometry.entitySourceRect = UIRect(
geometry.textureSourceRect.x,
geometry.textureSourceRect.y + geometry.textureSourceRect.height + 16.0f,
geometry.textureSourceRect.width,
96.0f);
geometry.projectTargetRect = UIRect(
geometry.targetsRect.x + 14.0f,
geometry.targetsRect.y + 54.0f,
geometry.targetsRect.width - 28.0f,
112.0f);
geometry.hierarchyTargetRect = UIRect(
geometry.projectTargetRect.x,
geometry.projectTargetRect.y + geometry.projectTargetRect.height + 18.0f,
geometry.projectTargetRect.width,
112.0f);
return geometry;
}
void DragDropValidationScene::HandlePointerDown(
const Geometry& geometry,
const UIPoint& position) {
if (RectContains(geometry.textureSourceRect, position)) {
BeginUIDragDrop(BuildTextureSource(position), m_dragState);
SetResult("Result: armed Texture Asset, move beyond threshold to activate");
return;
}
if (RectContains(geometry.entitySourceRect, position)) {
BeginUIDragDrop(BuildEntitySource(position), m_dragState);
SetResult("Result: armed Scene Entity, move beyond threshold to activate");
}
}
void DragDropValidationScene::HandlePointerMove(
const Geometry& geometry,
const UIPoint& position) {
if (!IsUIDragDropInProgress(m_dragState)) {
return;
}
UIDragDropResult result = {};
UpdateUIDragDropPointer(m_dragState, position, &result);
if (result.activated) {
SetResult("Result: drag became active after crossing activation distance");
}
UpdateHoveredTarget(geometry, position);
}
void DragDropValidationScene::HandlePointerUp(
const Geometry& geometry,
const UIPoint& position) {
if (!IsUIDragDropInProgress(m_dragState)) {
return;
}
UpdateHoveredTarget(geometry, position);
UIDragDropResult result = {};
if (!EndUIDragDrop(m_dragState, result)) {
return;
}
if (result.completed) {
m_lastDropText =
result.payloadItemId + " -> " + result.targetId + " (" + DescribeOperation(result.operation) + ")";
SetResult("Result: drop completed on " + result.targetId + " with " + DescribeOperation(result.operation));
return;
}
SetResult("Result: pointer released without accepted target, drag cancelled");
}
void DragDropValidationScene::HandleCancel(std::string reason) {
if (!IsUIDragDropInProgress(m_dragState)) {
return;
}
UIDragDropResult result = {};
CancelUIDragDrop(m_dragState, &result);
SetResult(std::move(reason));
}
void DragDropValidationScene::UpdateHoveredTarget(
const Geometry& geometry,
const UIPoint& position) {
if (!m_dragState.active) {
return;
}
UIDragDropResult result = {};
if (RectContains(geometry.projectTargetRect, position)) {
const UIDragDropTargetDescriptor projectTarget = BuildProjectTarget();
UpdateUIDragDropTarget(m_dragState, &projectTarget, &result);
} else if (RectContains(geometry.hierarchyTargetRect, position)) {
const UIDragDropTargetDescriptor hierarchyTarget = BuildHierarchyTarget();
UpdateUIDragDropTarget(m_dragState, &hierarchyTarget, &result);
} else {
UpdateUIDragDropTarget(m_dragState, nullptr, &result);
}
if (result.targetChanged) {
if (!HasResolvedUIDragDropTarget(m_dragState)) {
SetResult("Result: current hover target rejects payload type or operation");
} else {
SetResult("Result: hover target accepts payload with " + DescribeOperation(m_dragState.previewOperation));
}
}
}
void DragDropValidationScene::SetResult(std::string text) {
m_resultText = std::move(text);
}
bool DragDropValidationScene::RectContains(
const UIRect& rect,
const UIPoint& position) {
return position.x >= rect.x &&
position.x <= rect.x + rect.width &&
position.y >= rect.y &&
position.y <= rect.y + rect.height;
}
} // namespace XCEngine::Tests::CoreUI

View File

@@ -0,0 +1,62 @@
#pragma once
#include <XCEngine/UI/DrawData.h>
#include <XCEngine/UI/Widgets/UIDragDropInteraction.h>
#include <string>
#include <vector>
namespace XCEngine::Tests::CoreUI {
class DragDropValidationScene {
public:
static constexpr const char* ScenarioId = "core.input.drag_drop_basic";
void Reset();
void Update(
const std::vector<::XCEngine::UI::UIInputEvent>& events,
const ::XCEngine::UI::UIRect& viewportRect,
bool windowFocused);
void AppendDrawData(
::XCEngine::UI::UIDrawData& drawData,
const ::XCEngine::UI::UIRect& viewportRect) const;
private:
struct Geometry {
::XCEngine::UI::UIRect labRect = {};
::XCEngine::UI::UIRect statusRect = {};
::XCEngine::UI::UIRect sourcesRect = {};
::XCEngine::UI::UIRect targetsRect = {};
::XCEngine::UI::UIRect textureSourceRect = {};
::XCEngine::UI::UIRect entitySourceRect = {};
::XCEngine::UI::UIRect projectTargetRect = {};
::XCEngine::UI::UIRect hierarchyTargetRect = {};
};
Geometry BuildGeometry(const ::XCEngine::UI::UIRect& viewportRect) const;
void HandlePointerDown(
const Geometry& geometry,
const ::XCEngine::UI::UIPoint& position);
void HandlePointerMove(
const Geometry& geometry,
const ::XCEngine::UI::UIPoint& position);
void HandlePointerUp(
const Geometry& geometry,
const ::XCEngine::UI::UIPoint& position);
void HandleCancel(std::string reason);
void UpdateHoveredTarget(
const Geometry& geometry,
const ::XCEngine::UI::UIPoint& position);
void SetResult(std::string text);
static bool RectContains(
const ::XCEngine::UI::UIRect& rect,
const ::XCEngine::UI::UIPoint& position);
::XCEngine::UI::Widgets::UIDragDropState m_dragState = {};
::XCEngine::UI::UIPoint m_pointerPosition = {};
bool m_hasPointer = false;
std::string m_resultText = "Result: Ready";
std::string m_lastDropText = "(none)";
};
} // namespace XCEngine::Tests::CoreUI

View File

@@ -4,6 +4,7 @@ set(CORE_UI_TEST_SOURCES
${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_draw_data.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_drag_drop_interaction.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

View File

@@ -0,0 +1,164 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIDragDropInteraction.h>
#include <array>
namespace {
using XCEngine::UI::UIPoint;
using XCEngine::UI::Widgets::BeginUIDragDrop;
using XCEngine::UI::Widgets::CancelUIDragDrop;
using XCEngine::UI::Widgets::DoesUIDragDropPayloadTypeMatch;
using XCEngine::UI::Widgets::EndUIDragDrop;
using XCEngine::UI::Widgets::HasResolvedUIDragDropTarget;
using XCEngine::UI::Widgets::IsUIDragDropInProgress;
using XCEngine::UI::Widgets::ResolveUIDragDropOperation;
using XCEngine::UI::Widgets::UIDragDropOperation;
using XCEngine::UI::Widgets::UIDragDropPayload;
using XCEngine::UI::Widgets::UIDragDropResult;
using XCEngine::UI::Widgets::UIDragDropSourceDescriptor;
using XCEngine::UI::Widgets::UIDragDropState;
using XCEngine::UI::Widgets::UIDragDropTargetDescriptor;
using XCEngine::UI::Widgets::UpdateUIDragDropPointer;
using XCEngine::UI::Widgets::UpdateUIDragDropTarget;
UIDragDropSourceDescriptor BuildSource() {
UIDragDropSourceDescriptor descriptor = {};
descriptor.ownerId = 101u;
descriptor.sourceId = "project.asset.texture";
descriptor.pointerDownPosition = UIPoint(24.0f, 32.0f);
descriptor.payload = UIDragDropPayload{ "asset.texture", "tex-001", "Checker" };
descriptor.allowedOperations =
UIDragDropOperation::Copy |
UIDragDropOperation::Move;
descriptor.activationDistance = 5.0f;
return descriptor;
}
UIDragDropTargetDescriptor BuildTarget() {
static constexpr std::array<std::string_view, 1> kAcceptedTypes = {
"asset.texture"
};
UIDragDropTargetDescriptor target = {};
target.ownerId = 202u;
target.targetId = "project.browser";
target.acceptedPayloadTypes = kAcceptedTypes;
target.acceptedOperations =
UIDragDropOperation::Copy |
UIDragDropOperation::Link;
target.preferredOperation =
UIDragDropOperation::Copy |
UIDragDropOperation::Link;
return target;
}
void ActivateDrag(UIDragDropState& state) {
ASSERT_TRUE(BeginUIDragDrop(BuildSource(), state));
ASSERT_TRUE(UpdateUIDragDropPointer(state, UIPoint(40.0f, 48.0f)));
ASSERT_TRUE(state.active);
}
} // namespace
TEST(UIDragDropInteractionTest, PayloadTypeMatchAndOperationResolutionStayWithinSupportedSingleOperation) {
constexpr std::array<std::string_view, 2> acceptedTypes = {
"asset.texture",
"asset.material"
};
EXPECT_TRUE(DoesUIDragDropPayloadTypeMatch("asset.texture", acceptedTypes));
EXPECT_FALSE(DoesUIDragDropPayloadTypeMatch("scene.entity", acceptedTypes));
EXPECT_TRUE(DoesUIDragDropPayloadTypeMatch("anything", {}));
const UIDragDropTargetDescriptor target = BuildTarget();
EXPECT_EQ(
ResolveUIDragDropOperation(UIDragDropOperation::Copy, target),
UIDragDropOperation::Copy);
}
TEST(UIDragDropInteractionTest, PointerMustCrossActivationDistanceBeforeDragBecomesActive) {
UIDragDropState state = {};
ASSERT_TRUE(BeginUIDragDrop(BuildSource(), state));
EXPECT_TRUE(IsUIDragDropInProgress(state));
EXPECT_TRUE(state.armed);
EXPECT_FALSE(state.active);
UIDragDropResult result = {};
EXPECT_TRUE(UpdateUIDragDropPointer(state, UIPoint(27.0f, 35.0f), &result));
EXPECT_FALSE(result.activated);
EXPECT_FALSE(state.active);
EXPECT_TRUE(UpdateUIDragDropPointer(state, UIPoint(31.0f, 39.0f), &result));
EXPECT_TRUE(result.activated);
EXPECT_TRUE(state.active);
}
TEST(UIDragDropInteractionTest, TargetUpdatesReturnConsistentAcceptedAndRejectedSnapshots) {
UIDragDropState state = {};
ActivateDrag(state);
const UIDragDropTargetDescriptor acceptedTarget = BuildTarget();
UIDragDropResult result = {};
EXPECT_TRUE(UpdateUIDragDropTarget(state, &acceptedTarget, &result));
EXPECT_TRUE(result.targetChanged);
EXPECT_EQ(result.targetId, "project.browser");
EXPECT_EQ(result.operation, UIDragDropOperation::Copy);
EXPECT_TRUE(HasResolvedUIDragDropTarget(state));
EXPECT_TRUE(UpdateUIDragDropTarget(state, &acceptedTarget, &result));
EXPECT_FALSE(result.targetChanged);
EXPECT_EQ(result.targetId, "project.browser");
EXPECT_EQ(result.operation, UIDragDropOperation::Copy);
static constexpr std::array<std::string_view, 1> kRejectedTypes = {
"scene.entity"
};
UIDragDropTargetDescriptor rejectedTarget = acceptedTarget;
rejectedTarget.acceptedPayloadTypes = kRejectedTypes;
EXPECT_FALSE(UpdateUIDragDropTarget(state, &rejectedTarget, &result));
EXPECT_TRUE(result.targetChanged);
EXPECT_EQ(result.targetOwnerId, 0u);
EXPECT_TRUE(result.targetId.empty());
EXPECT_EQ(result.operation, UIDragDropOperation::None);
EXPECT_FALSE(HasResolvedUIDragDropTarget(state));
}
TEST(UIDragDropInteractionTest, ReleaseOverResolvedTargetCompletesAndResetsState) {
UIDragDropState state = {};
ActivateDrag(state);
const UIDragDropTargetDescriptor target = BuildTarget();
ASSERT_TRUE(UpdateUIDragDropTarget(state, &target));
UIDragDropResult result = {};
ASSERT_TRUE(EndUIDragDrop(state, result));
EXPECT_TRUE(result.completed);
EXPECT_FALSE(result.cancelled);
EXPECT_EQ(result.sourceId, "project.asset.texture");
EXPECT_EQ(result.targetId, "project.browser");
EXPECT_EQ(result.payloadTypeId, "asset.texture");
EXPECT_EQ(result.payloadItemId, "tex-001");
EXPECT_EQ(result.operation, UIDragDropOperation::Copy);
EXPECT_FALSE(IsUIDragDropInProgress(state));
}
TEST(UIDragDropInteractionTest, CancelOrReleaseWithoutResolvedTargetCancelsAndResetsState) {
UIDragDropState state = {};
ActivateDrag(state);
UIDragDropResult result = {};
ASSERT_TRUE(EndUIDragDrop(state, result));
EXPECT_FALSE(result.completed);
EXPECT_TRUE(result.cancelled);
EXPECT_EQ(result.sourceId, "project.asset.texture");
EXPECT_FALSE(IsUIDragDropInProgress(state));
ASSERT_TRUE(BeginUIDragDrop(BuildSource(), state));
ASSERT_TRUE(CancelUIDragDrop(state, &result));
EXPECT_TRUE(result.cancelled);
EXPECT_EQ(result.sourceId, "project.asset.texture");
EXPECT_FALSE(IsUIDragDropInProgress(state));
}

View File

@@ -59,6 +59,23 @@ TEST(UISelectionModelTest, MultiSelectionTracksMembershipAndPrimarySelection) {
EXPECT_FALSE(selection.SetPrimarySelection("missing"));
}
TEST(UISelectionModelTest, ToggleSelectionMembershipAddsAndRemovesWithoutDroppingOthers) {
UISelectionModel selection = {};
EXPECT_TRUE(selection.SetSelections({ "camera", "lights" }, "lights"));
EXPECT_TRUE(selection.ToggleSelectionMembership("scene"));
EXPECT_TRUE(selection.IsSelected("camera"));
EXPECT_TRUE(selection.IsSelected("lights"));
EXPECT_TRUE(selection.IsSelected("scene"));
EXPECT_EQ(selection.GetSelectedId(), "scene");
EXPECT_TRUE(selection.ToggleSelectionMembership("lights"));
EXPECT_TRUE(selection.IsSelected("camera"));
EXPECT_FALSE(selection.IsSelected("lights"));
EXPECT_TRUE(selection.IsSelected("scene"));
EXPECT_EQ(selection.GetSelectedId(), "scene");
}
TEST(UISelectionModelTest, SetSelectionsNormalizesDuplicatesAndKeepsRequestedPrimary) {
UISelectionModel selection = {};

View File

@@ -89,7 +89,11 @@ TEST(UIEditorMenuPopupTest, HitTestIgnoresSeparatorsAndFallsBackToPopupSurface)
EXPECT_EQ(itemHit.kind, UIEditorMenuPopupHitTargetKind::Item);
EXPECT_EQ(itemHit.index, 0u);
const auto separatorHit = HitTestUIEditorMenuPopup(layout, items, UIPoint(130.0f, 88.0f));
const UIRect separatorRect = layout.itemRects[1];
const UIPoint separatorCenter(
separatorRect.x + separatorRect.width * 0.5f,
separatorRect.y + separatorRect.height * 0.5f);
const auto separatorHit = HitTestUIEditorMenuPopup(layout, items, separatorCenter);
EXPECT_EQ(separatorHit.kind, UIEditorMenuPopupHitTargetKind::PopupSurface);
EXPECT_EQ(separatorHit.index, UIEditorMenuPopupInvalidIndex);
@@ -134,3 +138,34 @@ TEST(UIEditorMenuPopupTest, BackgroundAndForegroundEmitStableCommands) {
EXPECT_EQ(foregroundCommands[12].type, UIDrawCommandType::Text);
EXPECT_EQ(foregroundCommands[12].text, "Ctrl+W");
}
TEST(UIEditorMenuPopupTest, SubmenuIndicatorStaysInsideReservedRightEdgeSlot) {
const auto items = BuildItems();
XCEngine::UI::Editor::Widgets::UIEditorMenuPopupMetrics metrics = {};
const auto layout = BuildUIEditorMenuPopupLayout(
UIRect(100.0f, 50.0f, 220.0f, 118.0f),
items,
metrics);
UIDrawList foreground("MenuPopupForeground");
AppendUIEditorMenuPopupForeground(foreground, layout, items, {}, {}, metrics);
const auto& commands = foreground.GetCommands();
auto submenuIt = std::find_if(
commands.begin(),
commands.end(),
[](const auto& command) {
return command.type == UIDrawCommandType::Text && command.text == ">";
});
ASSERT_NE(submenuIt, commands.end());
const UIRect& submenuRect = layout.itemRects[2];
const float expectedLeft =
submenuRect.x + submenuRect.width -
metrics.shortcutInsetRight -
metrics.submenuIndicatorWidth;
EXPECT_FLOAT_EQ(submenuIt->position.x, expectedLeft);
EXPECT_LE(
submenuIt->position.x + metrics.estimatedGlyphWidth,
submenuRect.x + submenuRect.width - metrics.shortcutInsetRight + 0.001f);
}

View File

@@ -149,15 +149,16 @@ TEST(UIEditorTabStripTest, BackgroundAndForegroundEmitStableChromeCommands) {
UIDrawList background("TabStripBackground");
AppendUIEditorTabStripBackground(background, layout, state);
ASSERT_EQ(background.GetCommandCount(), 9u);
ASSERT_EQ(background.GetCommandCount(), 8u);
const auto& backgroundCommands = background.GetCommands();
EXPECT_EQ(backgroundCommands[0].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[3].type, UIDrawCommandType::RectOutline);
EXPECT_EQ(backgroundCommands[4].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[5].type, UIDrawCommandType::RectOutline);
EXPECT_EQ(backgroundCommands[1].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[2].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[3].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[4].type, UIDrawCommandType::RectOutline);
EXPECT_EQ(backgroundCommands[5].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[6].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[7].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[8].type, UIDrawCommandType::RectOutline);
EXPECT_EQ(backgroundCommands[7].type, UIDrawCommandType::RectOutline);
UIDrawList foreground("TabStripForeground");
AppendUIEditorTabStripForeground(foreground, layout, items, state);
@@ -198,13 +199,12 @@ TEST(UIEditorTabStripTest, ForegroundCentersTabLabelsWithinHeaderContentArea) {
ASSERT_EQ(commands[3].type, UIDrawCommandType::Text);
ASSERT_EQ(commands[6].type, UIDrawCommandType::Text);
const float padding = 8.0f;
const float firstExpectedX =
layout.tabHeaderRects[0].x + padding +
((layout.tabHeaderRects[0].width - padding * 2.0f) - items[0].desiredHeaderLabelWidth) * 0.5f;
layout.tabHeaderRects[0].x +
std::floor((layout.tabHeaderRects[0].width - items[0].desiredHeaderLabelWidth) * 0.5f);
const float secondExpectedX =
layout.tabHeaderRects[1].x + padding +
((layout.tabHeaderRects[1].width - padding * 2.0f) - items[1].desiredHeaderLabelWidth) * 0.5f;
layout.tabHeaderRects[1].x +
std::floor((layout.tabHeaderRects[1].width - items[1].desiredHeaderLabelWidth) * 0.5f);
EXPECT_FLOAT_EQ(commands[3].position.x, firstExpectedX);
EXPECT_FLOAT_EQ(commands[6].position.x, secondExpectedX);

View File

@@ -39,6 +39,16 @@ bool ContainsTextCommand(const UIDrawList& drawList, std::string_view text) {
return false;
}
bool ContainsCommandType(const UIDrawList& drawList, UIDrawCommandType type) {
for (const auto& command : drawList.GetCommands()) {
if (command.type == type) {
return true;
}
}
return false;
}
std::vector<UIEditorTreeViewItem> BuildTreeItems() {
return {
{ "scene", "Scene", 0u, false, 0.0f },
@@ -178,10 +188,38 @@ TEST(UIEditorTreeViewTest, BackgroundAndForegroundEmitStableCommands) {
AppendUIEditorTreeViewForeground(foreground, layout, items);
ASSERT_EQ(foreground.GetCommandCount(), 20u);
EXPECT_EQ(foreground.GetCommands()[0].type, UIDrawCommandType::PushClipRect);
EXPECT_TRUE(ContainsTextCommand(foreground, "v"));
EXPECT_TRUE(ContainsCommandType(foreground, UIDrawCommandType::FilledTriangle));
EXPECT_TRUE(ContainsTextCommand(foreground, "Scene"));
EXPECT_TRUE(ContainsTextCommand(foreground, "Camera"));
EXPECT_EQ(foreground.GetCommands()[19].type, UIDrawCommandType::PopClipRect);
}
TEST(UIEditorTreeViewTest, LeadingIconAddsImageCommandAndReservesIconRect) {
std::vector<UIEditorTreeViewItem> items = {
{ "folder", "Assets", 0u, true, 0.0f, XCEngine::UI::UITextureHandle { 1u, 18u, 18u } }
};
UIExpansionModel expansionModel = {};
UIEditorTreeViewMetrics metrics = {};
metrics.rowHeight = 20.0f;
metrics.horizontalPadding = 6.0f;
metrics.disclosureExtent = 18.0f;
metrics.disclosureLabelGap = 2.0f;
metrics.iconExtent = 18.0f;
metrics.iconLabelGap = 2.0f;
const UIEditorTreeViewLayout layout =
BuildUIEditorTreeViewLayout(UIRect(10.0f, 12.0f, 240.0f, 80.0f), items, expansionModel, metrics);
ASSERT_EQ(layout.iconRects.size(), 1u);
EXPECT_FLOAT_EQ(layout.iconRects[0].x, 36.0f);
EXPECT_FLOAT_EQ(layout.iconRects[0].y, 13.0f);
EXPECT_FLOAT_EQ(layout.labelRects[0].x, 56.0f);
UIDrawList foreground("TreeViewForegroundWithIcon");
AppendUIEditorTreeViewForeground(foreground, layout, items);
EXPECT_TRUE(ContainsCommandType(foreground, UIDrawCommandType::Image));
EXPECT_TRUE(ContainsTextCommand(foreground, "Assets"));
}
} // namespace

View File

@@ -1,6 +1,6 @@
# XCUI TEST_SPEC
日期: `2026-04-06`
日期: `2026-04-09`
## 1. 目标
@@ -44,8 +44,14 @@ tests/UI/
- `Editor`
- 面向编辑器 UI 的测试。
- 例如 editor host、editor shell、editor-only widget、editor domain 集成。
- 当前阶段采用固定代码样式Editor 默认样式、palette、metrics 与视觉语义由代码层固定维护,不再为 Editor 主题解析单独扩测试主线。
当前阶段的资源化方向约束:
- `Core` / `Runtime` 可以继续推进资源化、热重载与资源驱动验证。
- `Editor` 当前不以主题资源解析作为主线,验证重点放在 editor-only 结构、状态机、交互和固定代码样式。
禁止把 `Runtime``Editor` 混在同一个测试目录里。
当前正式验证入口固定为 `tests/UI`,不得把 `new_editor` 当作当前验证树的替代入口。
## 3. Unit 规范
@@ -72,6 +78,7 @@ tests/UI/
- 每次只暴露当前批次需要检查的操作区域,不做大杂烩面板。
- 界面中的操作提示默认使用中文,必要时可混用 `hover``focus``active``capture` 等术语。
- 测哪一层,就把场景放到哪一层的 `integration/` 目录下。
- `Editor` 集成验证当前只覆盖 editor-only 基础层;在 `Core / Editor` 基础层未收口前,不在 `new_editor` 中提前做业务面板验证。
`integration` 测试不负责:
@@ -165,12 +172,12 @@ Editor 集成测试只承载 editor-only 场景,不再承载共享 Core primit
- `F12` 手动截图。
- 截图只允许截当前 exe 自己的渲染结果。
- 截图输出到当前 scenario 自己的 `captures/` 目录。
- 截图只允许输出到当前构建目录下、当前 exe 自己的 `Debug/captures/<scenario>/` 目录。
输出格式:
- `captures/latest.png`
- `captures/history/<timestamp>_<index>_<reason>.png`
- `build/.../Debug/captures/<scenario>/latest.png`
- `build/.../Debug/captures/<scenario>/history/<timestamp>_<index>_<reason>.png`
原则:
@@ -185,15 +192,19 @@ XCUI 必须坚持自底向上的建设顺序:
2. 先补对应 `unit`
3. 再补一个聚焦的 `integration` exe。
4. 人工检查通过后再继续向上推进。
5. 基础层稳定后,才允许把能力接入 `new_editor` 宿主做装配冒烟;不反向把 `new_editor` 当作验证入口。
禁止事项:
- 先堆 editor 具体面板,再回头补底层。
-`new_editor` 当作 XCUI 主测试入口。
- 在基础层未完成前,把业务面板直接塞进 `new_editor` 作为主推进路径。
- 把一个验证 exe 做成综合试验场。
- 为了赶进度写跨层耦合的临时代码。
## 9. 当前入口约定
当前 XCUI 的正式验证入口是 `tests/UI`
`new_editor` 不是后续 XCUI 测试体系的主入口,也不继续承载新的测试场景扩展。
当前 XCUI 的正式验证入口是 `tests/UI`,其下的 `Core / Runtime / Editor` 三层测试树是唯一有效的当前验证体系
`new_editor` 只作为未来重建 editor 的产品宿主,不是当前测试入口,也不继续承载新的测试场景扩展。
`tests/UI/TEST_SPEC.md` 负责顶层测试分层、职责边界和执行规则。
`tests/UI/Editor/integration/README.md` 负责 Editor 集成验证的当前入口说明、场景清单、构建运行和截图约定。