332 lines
12 KiB
C++
332 lines
12 KiB
C++
|
|
#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);
|
||
|
|
}
|