#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); }