feat(xcui): close scroll view validation loop

This commit is contained in:
2026-04-06 13:13:17 +08:00
parent b14a4fb7bb
commit 0804052d6f
11 changed files with 477 additions and 2 deletions

View File

@@ -0,0 +1,331 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <limits>
#include <string>
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<float>::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",
"<View name=\"Scroll View Test\">\n"
" <Column padding=\"18\" gap=\"10\">\n"
" <Card title=\"Console\" subtitle=\"Scroll smoke\" height=\"200\">\n"
" <ScrollView id=\"log-scroll\" height=\"fill\">\n"
" <Column gap=\"8\">\n"
" <Text text=\"Line 01\" />\n"
" <Text text=\"Line 02\" />\n"
" <Text text=\"Line 03\" />\n"
" <Text text=\"Line 04\" />\n"
" <Text text=\"Line 05\" />\n"
" <Text text=\"Line 06\" />\n"
" <Text text=\"Line 07\" />\n"
" <Text text=\"Line 08\" />\n"
" <Text text=\"Line 09\" />\n"
" <Text text=\"Line 10\" />\n"
" <Text text=\"Line 11\" />\n"
" <Text text=\"Line 12\" />\n"
" </Column>\n"
" </ScrollView>\n"
" </Card>\n"
" </Column>\n"
"</View>\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",
"<View name=\"Scroll Outside Test\">\n"
" <Column padding=\"18\" gap=\"10\">\n"
" <Card title=\"Console\" subtitle=\"Outside wheel should do nothing\" height=\"200\">\n"
" <ScrollView id=\"log-scroll\" height=\"fill\">\n"
" <Column gap=\"8\">\n"
" <Text text=\"Line 01\" />\n"
" <Text text=\"Line 02\" />\n"
" <Text text=\"Line 03\" />\n"
" <Text text=\"Line 04\" />\n"
" <Text text=\"Line 05\" />\n"
" <Text text=\"Line 06\" />\n"
" <Text text=\"Line 07\" />\n"
" <Text text=\"Line 08\" />\n"
" <Text text=\"Line 09\" />\n"
" <Text text=\"Line 10\" />\n"
" <Text text=\"Line 11\" />\n"
" <Text text=\"Line 12\" />\n"
" </Column>\n"
" </ScrollView>\n"
" </Card>\n"
" <Card title=\"Outside Area\" subtitle=\"not scrollable\" height=\"72\" />\n"
" </Column>\n"
"</View>\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",
"<View name=\"Nested Scroll View Test\">\n"
" <Column padding=\"18\" gap=\"10\">\n"
" <ScrollView id=\"outer-scroll\" height=\"260\">\n"
" <Column gap=\"8\">\n"
" <Text text=\"Outer 01\" />\n"
" <Text text=\"Outer 02\" />\n"
" <Text text=\"Outer 03\" />\n"
" <Card title=\"Nested Panel\" subtitle=\"inner scroll host\" height=\"180\">\n"
" <ScrollView id=\"inner-scroll\" height=\"fill\">\n"
" <Column gap=\"8\">\n"
" <Text text=\"Inner 01\" />\n"
" <Text text=\"Inner 02\" />\n"
" <Text text=\"Inner 03\" />\n"
" <Text text=\"Inner 04\" />\n"
" <Text text=\"Inner 05\" />\n"
" <Text text=\"Inner 06\" />\n"
" <Text text=\"Inner 07\" />\n"
" <Text text=\"Inner 08\" />\n"
" <Text text=\"Inner 09\" />\n"
" <Text text=\"Inner 10\" />\n"
" <Text text=\"Inner 11\" />\n"
" <Text text=\"Inner 12\" />\n"
" </Column>\n"
" </ScrollView>\n"
" </Card>\n"
" <Text text=\"Outer 04\" />\n"
" <Text text=\"Outer 05\" />\n"
" <Text text=\"Outer 06\" />\n"
" <Text text=\"Outer 07\" />\n"
" <Text text=\"Outer 08\" />\n"
" <Text text=\"Outer 09\" />\n"
" </Column>\n"
" </ScrollView>\n"
" </Column>\n"
"</View>\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);
}