From 0804052d6ff513e168cb203ddee9eb2379776464 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Mon, 6 Apr 2026 13:13:17 +0800 Subject: [PATCH] feat(xcui): close scroll view validation loop --- .../Editor/integration/input/CMakeLists.txt | 2 + .../input/scroll_view/CMakeLists.txt | 35 ++ .../integration/input/scroll_view/View.xcui | 61 ++++ .../input/scroll_view/captures/.gitkeep | 1 + .../integration/input/scroll_view/main.cpp | 8 + .../integration/shared/src/Application.cpp | 20 ++ .../shared/src/EditorValidationScenario.cpp | 13 +- .../unit/test_editor_validation_registry.cpp | 6 + tests/UI/Runtime/unit/CMakeLists.txt | 1 + .../unit/test_ui_runtime_scroll_view.cpp | 331 ++++++++++++++++++ tests/UI/TEST_SPEC.md | 1 + 11 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 tests/UI/Editor/integration/input/scroll_view/CMakeLists.txt create mode 100644 tests/UI/Editor/integration/input/scroll_view/View.xcui create mode 100644 tests/UI/Editor/integration/input/scroll_view/captures/.gitkeep create mode 100644 tests/UI/Editor/integration/input/scroll_view/main.cpp create mode 100644 tests/UI/Runtime/unit/test_ui_runtime_scroll_view.cpp diff --git a/tests/UI/Editor/integration/input/CMakeLists.txt b/tests/UI/Editor/integration/input/CMakeLists.txt index 6de6309b..4d188ff6 100644 --- a/tests/UI/Editor/integration/input/CMakeLists.txt +++ b/tests/UI/Editor/integration/input/CMakeLists.txt @@ -1,10 +1,12 @@ add_subdirectory(keyboard_focus) add_subdirectory(pointer_states) +add_subdirectory(scroll_view) add_subdirectory(shortcut_scope) add_custom_target(editor_ui_input_integration_tests DEPENDS editor_ui_input_keyboard_focus_validation editor_ui_input_pointer_states_validation + editor_ui_input_scroll_view_validation editor_ui_input_shortcut_scope_validation ) diff --git a/tests/UI/Editor/integration/input/scroll_view/CMakeLists.txt b/tests/UI/Editor/integration/input/scroll_view/CMakeLists.txt new file mode 100644 index 00000000..1558b402 --- /dev/null +++ b/tests/UI/Editor/integration/input/scroll_view/CMakeLists.txt @@ -0,0 +1,35 @@ +set(EDITOR_UI_INPUT_SCROLL_VIEW_RESOURCES + View.xcui + ${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme +) + +add_executable(editor_ui_input_scroll_view_validation WIN32 + main.cpp + ${EDITOR_UI_INPUT_SCROLL_VIEW_RESOURCES} +) + +target_include_directories(editor_ui_input_scroll_view_validation PRIVATE + ${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src + ${CMAKE_SOURCE_DIR}/engine/include +) + +target_compile_definitions(editor_ui_input_scroll_view_validation PRIVATE + UNICODE + _UNICODE +) + +if(MSVC) + target_compile_options(editor_ui_input_scroll_view_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_input_scroll_view_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_input_scroll_view_validation PRIVATE + editor_ui_integration_host +) + +set_target_properties(editor_ui_input_scroll_view_validation PROPERTIES + OUTPUT_NAME "XCUIEditorInputScrollViewValidation" +) + +source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui) diff --git a/tests/UI/Editor/integration/input/scroll_view/View.xcui b/tests/UI/Editor/integration/input/scroll_view/View.xcui new file mode 100644 index 00000000..6b086936 --- /dev/null +++ b/tests/UI/Editor/integration/input/scroll_view/View.xcui @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/UI/Editor/integration/input/scroll_view/captures/.gitkeep b/tests/UI/Editor/integration/input/scroll_view/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/input/scroll_view/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/input/scroll_view/main.cpp b/tests/UI/Editor/integration/input/scroll_view/main.cpp new file mode 100644 index 00000000..47805554 --- /dev/null +++ b/tests/UI/Editor/integration/input/scroll_view/main.cpp @@ -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.scroll_view"); +} diff --git a/tests/UI/Editor/integration/shared/src/Application.cpp b/tests/UI/Editor/integration/shared/src/Application.cpp index ee9e4ce2..f2e85b06 100644 --- a/tests/UI/Editor/integration/shared/src/Application.cpp +++ b/tests/UI/Editor/integration/shared/src/Application.cpp @@ -530,6 +530,7 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float if (authoredMode) { const auto& inputDebug = m_documentHost.GetInputDebugSnapshot(); + const auto& scrollDebug = m_documentHost.GetScrollDebugSnapshot(); detailLines.push_back( "Hover | Focus: " + ExtractStateKeyTail(inputDebug.hoveredStateKey) + @@ -601,6 +602,25 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float "Last event result: " + (inputDebug.lastResult.empty() ? std::string("n/a") : inputDebug.lastResult)); } + detailLines.push_back( + "Scroll target | Primary: " + + ExtractStateKeyTail(scrollDebug.lastTargetStateKey) + + " | " + + ExtractStateKeyTail(scrollDebug.primaryTargetStateKey)); + detailLines.push_back( + "Scroll offset B/A: " + + FormatFloat(scrollDebug.lastOffsetBefore) + + " -> " + + FormatFloat(scrollDebug.lastOffsetAfter) + + " | overflow " + + FormatFloat(scrollDebug.lastOverflow)); + detailLines.push_back( + "Scroll H/T: " + + std::to_string(scrollDebug.handledWheelEventCount) + + "/" + + std::to_string(scrollDebug.totalWheelEventCount) + + " | " + + (scrollDebug.lastResult.empty() ? std::string("n/a") : scrollDebug.lastResult)); } if (m_autoScreenshot.HasPendingCapture()) { diff --git a/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp b/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp index 6ab54ec6..8674b963 100644 --- a/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp +++ b/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp @@ -24,8 +24,8 @@ fs::path RepoRelative(const char* relativePath) { return (RepoRootPath() / relativePath).lexically_normal(); } -const std::array& GetEditorValidationScenarios() { - static const std::array scenarios = { { +const std::array& GetEditorValidationScenarios() { + static const std::array scenarios = { { { "editor.input.keyboard_focus", UIValidationDomain::Editor, @@ -44,6 +44,15 @@ const std::array& GetEditorValidationScenarios() { RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"), RepoRelative("tests/UI/Editor/integration/input/pointer_states/captures") }, + { + "editor.input.scroll_view", + UIValidationDomain::Editor, + "input", + "Editor Input | Scroll View", + RepoRelative("tests/UI/Editor/integration/input/scroll_view/View.xcui"), + RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"), + RepoRelative("tests/UI/Editor/integration/input/scroll_view/captures") + }, { "editor.input.shortcut_scope", UIValidationDomain::Editor, diff --git a/tests/UI/Editor/unit/test_editor_validation_registry.cpp b/tests/UI/Editor/unit/test_editor_validation_registry.cpp index a6a762cc..33cb9ce4 100644 --- a/tests/UI/Editor/unit/test_editor_validation_registry.cpp +++ b/tests/UI/Editor/unit/test_editor_validation_registry.cpp @@ -15,6 +15,7 @@ using XCEngine::Tests::EditorUI::UIValidationDomain; TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExistingResources) { const auto* pointerScenario = FindEditorValidationScenario("editor.input.pointer_states"); const auto* keyboardScenario = FindEditorValidationScenario("editor.input.keyboard_focus"); + const auto* scrollScenario = FindEditorValidationScenario("editor.input.scroll_view"); const auto* shortcutScenario = FindEditorValidationScenario("editor.input.shortcut_scope"); const auto* splitterScenario = FindEditorValidationScenario("editor.layout.splitter_resize"); const auto* tabStripScenario = FindEditorValidationScenario("editor.layout.tab_strip_selection"); @@ -22,18 +23,21 @@ TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExisti ASSERT_NE(pointerScenario, nullptr); ASSERT_NE(keyboardScenario, nullptr); + ASSERT_NE(scrollScenario, nullptr); ASSERT_NE(shortcutScenario, nullptr); ASSERT_NE(splitterScenario, nullptr); ASSERT_NE(tabStripScenario, nullptr); ASSERT_NE(workspaceScenario, nullptr); EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Editor); EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Editor); + EXPECT_EQ(scrollScenario->domain, UIValidationDomain::Editor); EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Editor); EXPECT_EQ(splitterScenario->domain, UIValidationDomain::Editor); EXPECT_EQ(tabStripScenario->domain, UIValidationDomain::Editor); EXPECT_EQ(workspaceScenario->domain, UIValidationDomain::Editor); EXPECT_EQ(pointerScenario->categoryId, "input"); EXPECT_EQ(keyboardScenario->categoryId, "input"); + EXPECT_EQ(scrollScenario->categoryId, "input"); EXPECT_EQ(shortcutScenario->categoryId, "input"); EXPECT_EQ(splitterScenario->categoryId, "layout"); EXPECT_EQ(tabStripScenario->categoryId, "layout"); @@ -42,6 +46,8 @@ TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExisti EXPECT_TRUE(std::filesystem::exists(pointerScenario->themePath)); EXPECT_TRUE(std::filesystem::exists(keyboardScenario->documentPath)); EXPECT_TRUE(std::filesystem::exists(keyboardScenario->themePath)); + EXPECT_TRUE(std::filesystem::exists(scrollScenario->documentPath)); + EXPECT_TRUE(std::filesystem::exists(scrollScenario->themePath)); EXPECT_TRUE(std::filesystem::exists(shortcutScenario->documentPath)); EXPECT_TRUE(std::filesystem::exists(shortcutScenario->themePath)); EXPECT_TRUE(std::filesystem::exists(splitterScenario->documentPath)); diff --git a/tests/UI/Runtime/unit/CMakeLists.txt b/tests/UI/Runtime/unit/CMakeLists.txt index 003f003d..13dc251d 100644 --- a/tests/UI/Runtime/unit/CMakeLists.txt +++ b/tests/UI/Runtime/unit/CMakeLists.txt @@ -1,4 +1,5 @@ set(RUNTIME_UI_TEST_SOURCES + ${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_scroll_view.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_shortcut_scope.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_splitter_validation.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_tab_strip.cpp diff --git a/tests/UI/Runtime/unit/test_ui_runtime_scroll_view.cpp b/tests/UI/Runtime/unit/test_ui_runtime_scroll_view.cpp new file mode 100644 index 00000000..ecf4cb99 --- /dev/null +++ b/tests/UI/Runtime/unit/test_ui_runtime_scroll_view.cpp @@ -0,0 +1,331 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +using XCEngine::UI::UIDrawCommand; +using XCEngine::UI::UIDrawCommandType; +using XCEngine::UI::UIDrawData; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Runtime::UIScreenAsset; +using XCEngine::UI::Runtime::UIScreenFrameInput; +using XCEngine::UI::Runtime::UIScreenPlayer; +using XCEngine::UI::Runtime::UIDocumentScreenHost; + +namespace fs = std::filesystem; + +class TempFileScope { +public: + TempFileScope(std::string stem, std::string extension, std::string contents) { + const auto uniqueId = std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()); + m_path = fs::temp_directory_path() / (std::move(stem) + "_" + uniqueId + std::move(extension)); + std::ofstream output(m_path, std::ios::binary | std::ios::trunc); + output << contents; + } + + ~TempFileScope() { + std::error_code errorCode = {}; + fs::remove(m_path, errorCode); + } + + const fs::path& Path() const { + return m_path; + } + +private: + fs::path m_path = {}; +}; + +UIScreenAsset BuildScreenAsset(const fs::path& viewPath, const char* screenId) { + UIScreenAsset screen = {}; + screen.screenId = screenId; + screen.documentPath = viewPath.string(); + return screen; +} + +UIScreenFrameInput BuildInputState(std::uint64_t frameIndex = 1u) { + UIScreenFrameInput input = {}; + input.viewportRect = UIRect(0.0f, 0.0f, 520.0f, 340.0f); + input.frameIndex = frameIndex; + input.focused = true; + return input; +} + +const UIDrawCommand* FindTextCommand( + const UIDrawData& drawData, + const std::string& text) { + for (const UIDrawList& drawList : drawData.GetDrawLists()) { + for (const UIDrawCommand& command : drawList.GetCommands()) { + if (command.type == UIDrawCommandType::Text && + command.text == text) { + return &command; + } + } + } + + return nullptr; +} + +std::size_t CountCommandsOfType( + const UIDrawData& drawData, + UIDrawCommandType type) { + std::size_t count = 0u; + for (const UIDrawList& drawList : drawData.GetDrawLists()) { + for (const UIDrawCommand& command : drawList.GetCommands()) { + if (command.type == type) { + ++count; + } + } + } + return count; +} + +bool RectContainsPoint(const UIRect& rect, const UIPoint& point) { + return point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height; +} + +bool TryFindSmallestFilledRectContainingPoint( + const UIDrawData& drawData, + const UIPoint& point, + UIRect& outRect) { + bool found = false; + float bestArea = (std::numeric_limits::max)(); + + for (const UIDrawList& drawList : drawData.GetDrawLists()) { + for (const UIDrawCommand& command : drawList.GetCommands()) { + if (command.type != UIDrawCommandType::FilledRect || + !RectContainsPoint(command.rect, point)) { + continue; + } + + const float area = command.rect.width * command.rect.height; + if (!found || area < bestArea) { + outRect = command.rect; + bestArea = area; + found = true; + } + } + } + + return found; +} + +bool TryFindFilledRectForText( + const UIDrawData& drawData, + const std::string& text, + UIRect& outRect) { + const UIDrawCommand* textCommand = FindTextCommand(drawData, text); + return textCommand != nullptr && + TryFindSmallestFilledRectContainingPoint(drawData, textCommand->position, outRect); +} + +UIPoint GetRectCenter(const UIRect& rect) { + return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f); +} + +} // namespace + +TEST(UIRuntimeScrollViewTest, ScrollViewClipsOverflowingChildrenAndPersistsWheelOffset) { + TempFileScope viewFile( + "xcui_runtime_scroll_view", + ".xcui", + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"); + UIDocumentScreenHost host = {}; + UIScreenPlayer player(host); + + ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.scroll.view"))); + + UIScreenFrameInput firstInput = BuildInputState(1u); + const auto& firstFrame = player.Update(firstInput); + const auto* line01Before = FindTextCommand(firstFrame.drawData, "Line 01"); + ASSERT_NE(line01Before, nullptr); + EXPECT_GT(CountCommandsOfType(firstFrame.drawData, UIDrawCommandType::PushClipRect), 0u); + EXPECT_GT(CountCommandsOfType(firstFrame.drawData, UIDrawCommandType::PopClipRect), 0u); + + UIScreenFrameInput secondInput = BuildInputState(2u); + UIInputEvent wheelEvent = {}; + wheelEvent.type = UIInputEventType::PointerWheel; + wheelEvent.position = UIPoint(90.0f, 130.0f); + wheelEvent.wheelDelta = -120.0f; + secondInput.events.push_back(wheelEvent); + + const auto& secondFrame = player.Update(secondInput); + const auto* line01After = FindTextCommand(secondFrame.drawData, "Line 01"); + ASSERT_NE(line01After, nullptr); + EXPECT_LT(line01After->position.y, line01Before->position.y); + + const auto& scrollDebug = host.GetScrollDebugSnapshot(); + EXPECT_NE(scrollDebug.lastTargetStateKey.find("/log-scroll"), std::string::npos); + EXPECT_EQ(scrollDebug.lastResult, "Handled"); + EXPECT_EQ(scrollDebug.handledWheelEventCount, 1u); + EXPECT_LT(scrollDebug.lastOffsetBefore, scrollDebug.lastOffsetAfter); + + UIScreenFrameInput thirdInput = BuildInputState(3u); + const auto& thirdFrame = player.Update(thirdInput); + const auto* line01Persisted = FindTextCommand(thirdFrame.drawData, "Line 01"); + ASSERT_NE(line01Persisted, nullptr); + EXPECT_FLOAT_EQ(line01Persisted->position.y, line01After->position.y); +} + +TEST(UIRuntimeScrollViewTest, WheelOutsideScrollViewportDoesNotChangeContentOffset) { + TempFileScope viewFile( + "xcui_runtime_scroll_view_outside", + ".xcui", + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"); + UIDocumentScreenHost host = {}; + UIScreenPlayer player(host); + + ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.scroll.outside"))); + + UIScreenFrameInput firstInput = BuildInputState(1u); + const auto& firstFrame = player.Update(firstInput); + const auto* line01Before = FindTextCommand(firstFrame.drawData, "Line 01"); + ASSERT_NE(line01Before, nullptr); + + UIScreenFrameInput secondInput = BuildInputState(2u); + UIInputEvent wheelEvent = {}; + wheelEvent.type = UIInputEventType::PointerWheel; + wheelEvent.position = UIPoint(110.0f, 300.0f); + wheelEvent.wheelDelta = -120.0f; + secondInput.events.push_back(wheelEvent); + + const auto& secondFrame = player.Update(secondInput); + const auto* line01After = FindTextCommand(secondFrame.drawData, "Line 01"); + ASSERT_NE(line01After, nullptr); + EXPECT_FLOAT_EQ(line01After->position.y, line01Before->position.y); + + const auto& scrollDebug = host.GetScrollDebugSnapshot(); + EXPECT_EQ(scrollDebug.lastResult, "No hovered ScrollView"); + EXPECT_EQ(scrollDebug.handledWheelEventCount, 0u); +} + +TEST(UIRuntimeScrollViewTest, NestedScrollViewRoutesWheelToDeepestHoveredTarget) { + TempFileScope viewFile( + "xcui_runtime_nested_scroll_view", + ".xcui", + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"); + UIDocumentScreenHost host = {}; + UIScreenPlayer player(host); + + ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.scroll.nested"))); + + UIScreenFrameInput firstInput = BuildInputState(1u); + const auto& firstFrame = player.Update(firstInput); + const auto* innerBefore = FindTextCommand(firstFrame.drawData, "Inner 01"); + ASSERT_NE(innerBefore, nullptr); + + UIRect innerRect = {}; + ASSERT_TRUE(TryFindFilledRectForText(firstFrame.drawData, "Inner 01", innerRect)); + const UIPoint wheelPoint = GetRectCenter(innerRect); + + UIScreenFrameInput secondInput = BuildInputState(2u); + UIInputEvent wheelEvent = {}; + wheelEvent.type = UIInputEventType::PointerWheel; + wheelEvent.position = wheelPoint; + wheelEvent.wheelDelta = -120.0f; + secondInput.events.push_back(wheelEvent); + + const auto& secondFrame = player.Update(secondInput); + const auto* innerAfter = FindTextCommand(secondFrame.drawData, "Inner 01"); + ASSERT_NE(innerAfter, nullptr); + EXPECT_LT(innerAfter->position.y, innerBefore->position.y); + + const auto& scrollDebug = host.GetScrollDebugSnapshot(); + EXPECT_NE(scrollDebug.lastTargetStateKey.find("/inner-scroll"), std::string::npos); + EXPECT_NE(scrollDebug.primaryTargetStateKey.find("/outer-scroll"), std::string::npos); + EXPECT_EQ(scrollDebug.lastResult, "Handled"); + EXPECT_GT(scrollDebug.lastOverflow, 0.0f); +} diff --git a/tests/UI/TEST_SPEC.md b/tests/UI/TEST_SPEC.md index c3f4d78a..71700a8f 100644 --- a/tests/UI/TEST_SPEC.md +++ b/tests/UI/TEST_SPEC.md @@ -119,6 +119,7 @@ Runtime 的集成测试结构与 Editor 保持同一规范,但宿主职责必 - `editor.input.keyboard_focus` - `editor.input.pointer_states` +- `editor.input.scroll_view` - `editor.input.shortcut_scope` - `editor.layout.splitter_resize` - `editor.layout.tab_strip_selection`