2026-04-05 05:14:16 +08:00
|
|
|
#include <gtest/gtest.h>
|
|
|
|
|
|
|
|
|
|
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
|
|
|
|
|
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
|
2026-04-05 12:50:55 +08:00
|
|
|
#include <XCEngine/UI/Runtime/UISceneRuntimeContext.h>
|
2026-04-05 06:05:54 +08:00
|
|
|
#include <XCEngine/UI/Runtime/UIScreenStackController.h>
|
2026-04-05 05:14:16 +08:00
|
|
|
#include <XCEngine/UI/Runtime/UISystem.h>
|
|
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
#include <chrono>
|
2026-04-05 05:14:16 +08:00
|
|
|
#include <filesystem>
|
|
|
|
|
#include <fstream>
|
2026-04-05 20:46:24 +08:00
|
|
|
#include <limits>
|
2026-04-05 05:44:07 +08:00
|
|
|
#include <string>
|
2026-04-05 12:50:55 +08:00
|
|
|
#include <vector>
|
2026-04-05 05:14:16 +08:00
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
using XCEngine::UI::Runtime::UIScreenAsset;
|
|
|
|
|
using XCEngine::UI::Runtime::UIScreenFrameInput;
|
|
|
|
|
using XCEngine::UI::Runtime::UIScreenPlayer;
|
2026-04-05 12:50:55 +08:00
|
|
|
using XCEngine::UI::Runtime::UISceneRuntimeContext;
|
2026-04-05 05:14:16 +08:00
|
|
|
using XCEngine::UI::Runtime::UIDocumentScreenHost;
|
2026-04-05 06:05:54 +08:00
|
|
|
using XCEngine::UI::Runtime::UIScreenStackController;
|
2026-04-05 05:14:16 +08:00
|
|
|
using XCEngine::UI::Runtime::UISystem;
|
|
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
namespace fs = std::filesystem;
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
~TempFileScope() {
|
|
|
|
|
std::error_code ec;
|
|
|
|
|
fs::remove(m_path, ec);
|
|
|
|
|
}
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
const fs::path& Path() const {
|
|
|
|
|
return m_path;
|
2026-04-05 05:14:16 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
private:
|
|
|
|
|
fs::path m_path = {};
|
|
|
|
|
};
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
std::string BuildViewMarkup(const char* heroTitle, const char* overlayText = nullptr) {
|
|
|
|
|
std::string markup =
|
|
|
|
|
"<View name=\"Runtime Screen\">\n"
|
|
|
|
|
" <Column id=\"root\" padding=\"18\" gap=\"10\">\n"
|
|
|
|
|
" <Card id=\"hero\" title=\"" + std::string(heroTitle) + "\" subtitle=\"Shared XCUI runtime layer\" />\n"
|
|
|
|
|
" <Text id=\"status\" text=\"Ready for play\" />\n"
|
|
|
|
|
" <Row id=\"actions\" gap=\"12\">\n"
|
|
|
|
|
" <Button id=\"start\" text=\"Start\" />\n"
|
|
|
|
|
" <Button id=\"options\" text=\"Options\" />\n"
|
|
|
|
|
" </Row>\n";
|
|
|
|
|
if (overlayText != nullptr) {
|
|
|
|
|
markup += " <Card id=\"overlay\" title=\"" + std::string(overlayText) + "\" tone=\"accent\" />\n";
|
2026-04-05 05:14:16 +08:00
|
|
|
}
|
2026-04-05 05:44:07 +08:00
|
|
|
markup +=
|
|
|
|
|
" </Column>\n"
|
|
|
|
|
"</View>\n";
|
|
|
|
|
return markup;
|
|
|
|
|
}
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
UIScreenAsset BuildScreenAsset(const fs::path& viewPath, const char* screenId) {
|
|
|
|
|
UIScreenAsset screen = {};
|
|
|
|
|
screen.screenId = screenId;
|
|
|
|
|
screen.documentPath = viewPath.string();
|
|
|
|
|
return screen;
|
|
|
|
|
}
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
bool DrawDataContainsText(
|
|
|
|
|
const XCEngine::UI::UIDrawData& drawData,
|
|
|
|
|
const std::string& text) {
|
|
|
|
|
for (const XCEngine::UI::UIDrawList& drawList : drawData.GetDrawLists()) {
|
|
|
|
|
for (const XCEngine::UI::UIDrawCommand& command : drawList.GetCommands()) {
|
|
|
|
|
if (command.type == XCEngine::UI::UIDrawCommandType::Text &&
|
|
|
|
|
command.text == text) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
2026-04-05 05:14:16 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 20:46:24 +08:00
|
|
|
std::vector<XCEngine::UI::UIRect> CollectFilledRects(
|
|
|
|
|
const XCEngine::UI::UIDrawData& drawData) {
|
|
|
|
|
std::vector<XCEngine::UI::UIRect> rects = {};
|
|
|
|
|
for (const XCEngine::UI::UIDrawList& drawList : drawData.GetDrawLists()) {
|
|
|
|
|
for (const XCEngine::UI::UIDrawCommand& command : drawList.GetCommands()) {
|
|
|
|
|
if (command.type == XCEngine::UI::UIDrawCommandType::FilledRect) {
|
|
|
|
|
rects.push_back(command.rect);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return rects;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const XCEngine::UI::UIDrawCommand* FindTextCommand(
|
|
|
|
|
const XCEngine::UI::UIDrawData& drawData,
|
|
|
|
|
const std::string& text) {
|
|
|
|
|
for (const XCEngine::UI::UIDrawList& drawList : drawData.GetDrawLists()) {
|
|
|
|
|
for (const XCEngine::UI::UIDrawCommand& command : drawList.GetCommands()) {
|
|
|
|
|
if (command.type == XCEngine::UI::UIDrawCommandType::Text &&
|
|
|
|
|
command.text == text) {
|
|
|
|
|
return &command;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool RectContainsPoint(
|
|
|
|
|
const XCEngine::UI::UIRect& rect,
|
|
|
|
|
const XCEngine::UI::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 XCEngine::UI::UIDrawData& drawData,
|
|
|
|
|
const XCEngine::UI::UIPoint& point,
|
|
|
|
|
XCEngine::UI::UIRect& outRect) {
|
|
|
|
|
bool found = false;
|
|
|
|
|
float bestArea = (std::numeric_limits<float>::max)();
|
|
|
|
|
|
|
|
|
|
for (const XCEngine::UI::UIDrawList& drawList : drawData.GetDrawLists()) {
|
|
|
|
|
for (const XCEngine::UI::UIDrawCommand& command : drawList.GetCommands()) {
|
|
|
|
|
if (command.type != XCEngine::UI::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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 22:35:24 +08:00
|
|
|
bool TryFindFilledRectForText(
|
|
|
|
|
const XCEngine::UI::UIDrawData& drawData,
|
|
|
|
|
const std::string& text,
|
|
|
|
|
XCEngine::UI::UIRect& outRect) {
|
|
|
|
|
const auto* textCommand = FindTextCommand(drawData, text);
|
|
|
|
|
return textCommand != nullptr &&
|
|
|
|
|
TryFindSmallestFilledRectContainingPoint(drawData, textCommand->position, outRect);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::UIPoint GetRectCenter(const XCEngine::UI::UIRect& rect) {
|
|
|
|
|
return XCEngine::UI::UIPoint(
|
|
|
|
|
rect.x + rect.width * 0.5f,
|
|
|
|
|
rect.y + rect.height * 0.5f);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 21:27:00 +08:00
|
|
|
std::size_t CountCommandsOfType(
|
|
|
|
|
const XCEngine::UI::UIDrawData& drawData,
|
|
|
|
|
XCEngine::UI::UIDrawCommandType type) {
|
|
|
|
|
std::size_t count = 0u;
|
|
|
|
|
for (const XCEngine::UI::UIDrawList& drawList : drawData.GetDrawLists()) {
|
|
|
|
|
for (const XCEngine::UI::UIDrawCommand& command : drawList.GetCommands()) {
|
|
|
|
|
if (command.type == type) {
|
|
|
|
|
++count;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
UIScreenFrameInput BuildInputState(std::uint64_t frameIndex = 1u) {
|
2026-04-05 05:14:16 +08:00
|
|
|
UIScreenFrameInput input = {};
|
2026-04-05 05:44:07 +08:00
|
|
|
input.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 800.0f, 480.0f);
|
2026-04-05 05:14:16 +08:00
|
|
|
input.frameIndex = frameIndex;
|
|
|
|
|
input.focused = true;
|
|
|
|
|
return input;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 12:50:55 +08:00
|
|
|
const XCEngine::UI::Runtime::UISystemPresentedLayer* FindPresentedLayerById(
|
|
|
|
|
const XCEngine::UI::Runtime::UISystemFrameResult& frame,
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenLayerId layerId) {
|
|
|
|
|
for (const XCEngine::UI::Runtime::UISystemPresentedLayer& layer : frame.layers) {
|
|
|
|
|
if (layer.layerId == layerId) {
|
|
|
|
|
return &layer;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class RecordingDocumentHost final : public XCEngine::UI::Runtime::IUIScreenDocumentHost {
|
|
|
|
|
public:
|
|
|
|
|
struct BuildCall {
|
|
|
|
|
std::string displayName = {};
|
|
|
|
|
UIScreenFrameInput input = {};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenLoadResult LoadScreen(const UIScreenAsset& asset) override {
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenLoadResult result = {};
|
|
|
|
|
result.succeeded = asset.IsValid();
|
|
|
|
|
result.document.sourcePath = asset.documentPath;
|
|
|
|
|
result.document.displayName = asset.screenId.empty() ? asset.documentPath : asset.screenId;
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenFrameResult BuildFrame(
|
|
|
|
|
const XCEngine::UI::Runtime::UIScreenDocument& document,
|
|
|
|
|
const UIScreenFrameInput& input) override {
|
|
|
|
|
m_buildCalls.push_back(BuildCall{ document.displayName, input });
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenFrameResult result = {};
|
|
|
|
|
result.stats.documentLoaded = true;
|
|
|
|
|
result.stats.inputEventCount = input.events.size();
|
|
|
|
|
result.stats.presentedFrameIndex = input.frameIndex;
|
|
|
|
|
XCEngine::UI::UIDrawList& drawList = result.drawData.EmplaceDrawList(document.displayName);
|
|
|
|
|
drawList.AddText(
|
|
|
|
|
XCEngine::UI::UIPoint(input.viewportRect.x, input.viewportRect.y),
|
|
|
|
|
document.displayName);
|
|
|
|
|
result.stats.drawListCount = result.drawData.GetDrawListCount();
|
|
|
|
|
result.stats.commandCount = result.drawData.GetTotalCommandCount();
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BuildCall* FindBuildCall(const std::string& displayName) const {
|
|
|
|
|
for (const BuildCall& call : m_buildCalls) {
|
|
|
|
|
if (call.displayName == displayName) {
|
|
|
|
|
return &call;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::size_t GetBuildCallCount() const {
|
|
|
|
|
return m_buildCalls.size();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private:
|
|
|
|
|
std::vector<BuildCall> m_buildCalls = {};
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
} // namespace
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
TEST(UIRuntimeTest, ScreenPlayerBuildsDrawDataFromDocumentTree) {
|
|
|
|
|
TempFileScope viewFile("xcui_runtime_screen", ".xcui", BuildViewMarkup("Runtime HUD"));
|
|
|
|
|
UIDocumentScreenHost host = {};
|
2026-04-05 05:14:16 +08:00
|
|
|
UIScreenPlayer player(host);
|
|
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.main_menu")));
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
const auto& frame = player.Update(BuildInputState());
|
|
|
|
|
EXPECT_TRUE(frame.stats.documentLoaded);
|
|
|
|
|
EXPECT_EQ(frame.stats.nodeCount, 7u);
|
|
|
|
|
EXPECT_EQ(frame.stats.drawListCount, frame.drawData.GetDrawListCount());
|
|
|
|
|
EXPECT_EQ(frame.stats.commandCount, frame.drawData.GetTotalCommandCount());
|
|
|
|
|
EXPECT_GE(frame.stats.textCommandCount, 5u);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Runtime HUD"));
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Ready for play"));
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Start"));
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Options"));
|
2026-04-05 05:14:16 +08:00
|
|
|
EXPECT_EQ(player.GetPresentedFrameCount(), 1u);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 20:46:24 +08:00
|
|
|
TEST(UIRuntimeTest, DocumentHostStretchesColumnChildrenAcrossCrossAxis) {
|
|
|
|
|
TempFileScope viewFile(
|
|
|
|
|
"xcui_runtime_stretch_column",
|
|
|
|
|
".xcui",
|
|
|
|
|
"<View name=\"Stretch Column\">\n"
|
|
|
|
|
" <Column padding=\"18\" gap=\"10\">\n"
|
|
|
|
|
" <Button text=\"Wide Button\" />\n"
|
|
|
|
|
" </Column>\n"
|
|
|
|
|
"</View>\n");
|
|
|
|
|
UIDocumentScreenHost host = {};
|
|
|
|
|
UIScreenPlayer player(host);
|
|
|
|
|
|
|
|
|
|
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.stretch.column")));
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput input = BuildInputState();
|
|
|
|
|
input.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 400.0f, 220.0f);
|
|
|
|
|
const auto& frame = player.Update(input);
|
|
|
|
|
const auto* buttonText = FindTextCommand(frame.drawData, "Wide Button");
|
|
|
|
|
ASSERT_NE(buttonText, nullptr);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::UIRect buttonRect = {};
|
|
|
|
|
ASSERT_TRUE(TryFindSmallestFilledRectContainingPoint(frame.drawData, buttonText->position, buttonRect));
|
|
|
|
|
EXPECT_FLOAT_EQ(buttonRect.x, 34.0f);
|
|
|
|
|
EXPECT_FLOAT_EQ(buttonRect.width, 332.0f);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(UIRuntimeTest, DocumentHostDoesNotLetExplicitHeightCrushCardContent) {
|
|
|
|
|
TempFileScope viewFile(
|
|
|
|
|
"xcui_runtime_card_height_floor",
|
|
|
|
|
".xcui",
|
|
|
|
|
"<View name=\"Card Height Floor\">\n"
|
|
|
|
|
" <Column padding=\"18\" gap=\"10\">\n"
|
|
|
|
|
" <Card title=\"Hero\" subtitle=\"Subtitle\" height=\"32\">\n"
|
|
|
|
|
" <Row gap=\"10\">\n"
|
|
|
|
|
" <Button text=\"Action\" />\n"
|
|
|
|
|
" </Row>\n"
|
|
|
|
|
" </Card>\n"
|
|
|
|
|
" </Column>\n"
|
|
|
|
|
"</View>\n");
|
|
|
|
|
UIDocumentScreenHost host = {};
|
|
|
|
|
UIScreenPlayer player(host);
|
|
|
|
|
|
|
|
|
|
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.card.height.floor")));
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput input = BuildInputState();
|
|
|
|
|
input.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 500.0f, 300.0f);
|
|
|
|
|
const auto& frame = player.Update(input);
|
|
|
|
|
const auto* heroTitle = FindTextCommand(frame.drawData, "Hero");
|
|
|
|
|
const auto* actionText = FindTextCommand(frame.drawData, "Action");
|
|
|
|
|
ASSERT_NE(heroTitle, nullptr);
|
|
|
|
|
ASSERT_NE(actionText, nullptr);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::UIRect cardRect = {};
|
|
|
|
|
XCEngine::UI::UIRect buttonRect = {};
|
|
|
|
|
ASSERT_TRUE(TryFindSmallestFilledRectContainingPoint(frame.drawData, heroTitle->position, cardRect));
|
|
|
|
|
ASSERT_TRUE(TryFindSmallestFilledRectContainingPoint(frame.drawData, actionText->position, buttonRect));
|
|
|
|
|
|
|
|
|
|
EXPECT_GT(cardRect.height, 32.0f);
|
|
|
|
|
EXPECT_GE(buttonRect.y, cardRect.y + 50.0f);
|
|
|
|
|
EXPECT_LE(buttonRect.y + buttonRect.height, cardRect.y + cardRect.height - 8.0f);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 21:27:00 +08:00
|
|
|
TEST(UIRuntimeTest, DocumentHostScrollViewClipsOverflowingChildrenAndRespondsToWheelInput) {
|
|
|
|
|
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);
|
|
|
|
|
firstInput.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 480.0f, 320.0f);
|
|
|
|
|
const auto& firstFrame = player.Update(firstInput);
|
|
|
|
|
const auto* line01Before = FindTextCommand(firstFrame.drawData, "Line 01");
|
|
|
|
|
ASSERT_NE(line01Before, nullptr);
|
|
|
|
|
const float line01BeforeY = line01Before->position.y;
|
|
|
|
|
EXPECT_GT(CountCommandsOfType(firstFrame.drawData, XCEngine::UI::UIDrawCommandType::PushClipRect), 0u);
|
|
|
|
|
EXPECT_GT(CountCommandsOfType(firstFrame.drawData, XCEngine::UI::UIDrawCommandType::PopClipRect), 0u);
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput secondInput = BuildInputState(2u);
|
|
|
|
|
secondInput.viewportRect = firstInput.viewportRect;
|
|
|
|
|
XCEngine::UI::UIInputEvent wheelEvent = {};
|
|
|
|
|
wheelEvent.type = XCEngine::UI::UIInputEventType::PointerWheel;
|
|
|
|
|
wheelEvent.position = XCEngine::UI::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);
|
|
|
|
|
const float line01AfterY = line01After->position.y;
|
|
|
|
|
EXPECT_LT(line01AfterY, line01BeforeY);
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput thirdInput = BuildInputState(3u);
|
|
|
|
|
thirdInput.viewportRect = firstInput.viewportRect;
|
|
|
|
|
const auto& thirdFrame = player.Update(thirdInput);
|
|
|
|
|
const auto* line01Persisted = FindTextCommand(thirdFrame.drawData, "Line 01");
|
|
|
|
|
ASSERT_NE(line01Persisted, nullptr);
|
|
|
|
|
EXPECT_FLOAT_EQ(line01Persisted->position.y, line01AfterY);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 22:35:24 +08:00
|
|
|
TEST(UIRuntimeTest, DocumentHostTracksHoverFocusAndPointerCaptureAcrossFrames) {
|
|
|
|
|
TempFileScope viewFile(
|
|
|
|
|
"xcui_runtime_input_states",
|
|
|
|
|
".xcui",
|
|
|
|
|
"<View name=\"Input State Test\">\n"
|
|
|
|
|
" <Column padding=\"18\" gap=\"10\">\n"
|
|
|
|
|
" <Button id=\"input-hover\" text=\"Hover / Focus\" />\n"
|
|
|
|
|
" <Button id=\"input-capture\" text=\"Pointer Capture\" capturePointer=\"true\" />\n"
|
|
|
|
|
" <Button id=\"input-route\" text=\"Route Target\" />\n"
|
|
|
|
|
" </Column>\n"
|
|
|
|
|
"</View>\n");
|
|
|
|
|
UIDocumentScreenHost host = {};
|
|
|
|
|
UIScreenPlayer player(host);
|
|
|
|
|
|
|
|
|
|
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.input.states")));
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput firstInput = BuildInputState(1u);
|
|
|
|
|
firstInput.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 520.0f, 260.0f);
|
|
|
|
|
const auto& firstFrame = player.Update(firstInput);
|
|
|
|
|
const auto* hoverButtonText = FindTextCommand(firstFrame.drawData, "Hover / Focus");
|
|
|
|
|
const auto* captureButtonText = FindTextCommand(firstFrame.drawData, "Pointer Capture");
|
|
|
|
|
const auto* routeButtonText = FindTextCommand(firstFrame.drawData, "Route Target");
|
|
|
|
|
ASSERT_NE(hoverButtonText, nullptr);
|
|
|
|
|
ASSERT_NE(captureButtonText, nullptr);
|
|
|
|
|
ASSERT_NE(routeButtonText, nullptr);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::UIRect hoverButtonRect = {};
|
|
|
|
|
XCEngine::UI::UIRect captureButtonRect = {};
|
|
|
|
|
XCEngine::UI::UIRect routeButtonRect = {};
|
|
|
|
|
ASSERT_TRUE(TryFindFilledRectForText(firstFrame.drawData, "Hover / Focus", hoverButtonRect));
|
|
|
|
|
ASSERT_TRUE(TryFindFilledRectForText(firstFrame.drawData, "Pointer Capture", captureButtonRect));
|
|
|
|
|
ASSERT_TRUE(TryFindFilledRectForText(firstFrame.drawData, "Route Target", routeButtonRect));
|
|
|
|
|
|
|
|
|
|
const XCEngine::UI::UIPoint hoverButtonPoint = GetRectCenter(hoverButtonRect);
|
|
|
|
|
const XCEngine::UI::UIPoint captureButtonPoint = GetRectCenter(captureButtonRect);
|
|
|
|
|
const XCEngine::UI::UIPoint routeButtonPoint = GetRectCenter(routeButtonRect);
|
|
|
|
|
|
|
|
|
|
SCOPED_TRACE(
|
|
|
|
|
std::string("hoverPos=") + std::to_string(hoverButtonText->position.x) + "," + std::to_string(hoverButtonText->position.y) +
|
|
|
|
|
" hoverRect=" + std::to_string(hoverButtonRect.x) + "," + std::to_string(hoverButtonRect.y) + "," +
|
|
|
|
|
std::to_string(hoverButtonRect.width) + "," + std::to_string(hoverButtonRect.height) +
|
|
|
|
|
" capturePos=" + std::to_string(captureButtonText->position.x) + "," + std::to_string(captureButtonText->position.y) +
|
|
|
|
|
" captureRect=" + std::to_string(captureButtonRect.x) + "," + std::to_string(captureButtonRect.y) + "," +
|
|
|
|
|
std::to_string(captureButtonRect.width) + "," + std::to_string(captureButtonRect.height) +
|
|
|
|
|
" routePos=" + std::to_string(routeButtonText->position.x) + "," + std::to_string(routeButtonText->position.y) +
|
|
|
|
|
" routeRect=" + std::to_string(routeButtonRect.x) + "," + std::to_string(routeButtonRect.y) + "," +
|
|
|
|
|
std::to_string(routeButtonRect.width) + "," + std::to_string(routeButtonRect.height));
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput hoverInput = BuildInputState(2u);
|
|
|
|
|
hoverInput.viewportRect = firstInput.viewportRect;
|
|
|
|
|
XCEngine::UI::UIInputEvent hoverEvent = {};
|
|
|
|
|
hoverEvent.type = XCEngine::UI::UIInputEventType::PointerMove;
|
|
|
|
|
hoverEvent.position = hoverButtonPoint;
|
|
|
|
|
hoverInput.events.push_back(hoverEvent);
|
|
|
|
|
player.Update(hoverInput);
|
|
|
|
|
const auto& afterHover = host.GetInputDebugSnapshot();
|
|
|
|
|
EXPECT_NE(afterHover.hoveredStateKey.find("/input-hover"), std::string::npos);
|
|
|
|
|
EXPECT_TRUE(afterHover.focusedStateKey.empty());
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput captureDownInput = BuildInputState(3u);
|
|
|
|
|
captureDownInput.viewportRect = firstInput.viewportRect;
|
|
|
|
|
XCEngine::UI::UIInputEvent captureDownEvent = {};
|
|
|
|
|
captureDownEvent.type = XCEngine::UI::UIInputEventType::PointerButtonDown;
|
|
|
|
|
captureDownEvent.pointerButton = XCEngine::UI::UIPointerButton::Left;
|
|
|
|
|
captureDownEvent.position = captureButtonPoint;
|
|
|
|
|
captureDownInput.events.push_back(captureDownEvent);
|
|
|
|
|
player.Update(captureDownInput);
|
|
|
|
|
const auto& afterCaptureDown = host.GetInputDebugSnapshot();
|
|
|
|
|
SCOPED_TRACE(
|
|
|
|
|
std::string("afterCaptureDown hovered=") + afterCaptureDown.hoveredStateKey +
|
|
|
|
|
" focused=" + afterCaptureDown.focusedStateKey +
|
|
|
|
|
" active=" + afterCaptureDown.activeStateKey +
|
|
|
|
|
" capture=" + afterCaptureDown.captureStateKey +
|
|
|
|
|
" lastKind=" + afterCaptureDown.lastTargetKind +
|
|
|
|
|
" lastTarget=" + afterCaptureDown.lastTargetStateKey +
|
|
|
|
|
" result=" + afterCaptureDown.lastResult);
|
|
|
|
|
EXPECT_NE(afterCaptureDown.focusedStateKey.find("/input-capture"), std::string::npos);
|
|
|
|
|
EXPECT_NE(afterCaptureDown.activeStateKey.find("/input-capture"), std::string::npos);
|
|
|
|
|
EXPECT_NE(afterCaptureDown.captureStateKey.find("/input-capture"), std::string::npos);
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput dragInput = BuildInputState(4u);
|
|
|
|
|
dragInput.viewportRect = firstInput.viewportRect;
|
|
|
|
|
XCEngine::UI::UIInputEvent dragEvent = {};
|
|
|
|
|
dragEvent.type = XCEngine::UI::UIInputEventType::PointerMove;
|
|
|
|
|
dragEvent.position = routeButtonPoint;
|
|
|
|
|
dragInput.events.push_back(dragEvent);
|
|
|
|
|
player.Update(dragInput);
|
|
|
|
|
const auto& afterDrag = host.GetInputDebugSnapshot();
|
|
|
|
|
SCOPED_TRACE(
|
|
|
|
|
std::string("afterDrag hovered=") + afterDrag.hoveredStateKey +
|
|
|
|
|
" focused=" + afterDrag.focusedStateKey +
|
|
|
|
|
" active=" + afterDrag.activeStateKey +
|
|
|
|
|
" capture=" + afterDrag.captureStateKey +
|
|
|
|
|
" lastKind=" + afterDrag.lastTargetKind +
|
|
|
|
|
" lastTarget=" + afterDrag.lastTargetStateKey +
|
|
|
|
|
" result=" + afterDrag.lastResult);
|
|
|
|
|
EXPECT_NE(afterDrag.hoveredStateKey.find("/input-route"), std::string::npos);
|
|
|
|
|
EXPECT_NE(afterDrag.captureStateKey.find("/input-capture"), std::string::npos);
|
|
|
|
|
EXPECT_EQ(afterDrag.lastTargetKind, "Captured");
|
|
|
|
|
EXPECT_NE(afterDrag.lastTargetStateKey.find("/input-capture"), std::string::npos);
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput releaseInput = BuildInputState(5u);
|
|
|
|
|
releaseInput.viewportRect = firstInput.viewportRect;
|
|
|
|
|
XCEngine::UI::UIInputEvent releaseEvent = {};
|
|
|
|
|
releaseEvent.type = XCEngine::UI::UIInputEventType::PointerButtonUp;
|
|
|
|
|
releaseEvent.pointerButton = XCEngine::UI::UIPointerButton::Left;
|
|
|
|
|
releaseEvent.position = routeButtonPoint;
|
|
|
|
|
releaseInput.events.push_back(releaseEvent);
|
|
|
|
|
player.Update(releaseInput);
|
|
|
|
|
const auto& afterRelease = host.GetInputDebugSnapshot();
|
|
|
|
|
EXPECT_TRUE(afterRelease.activeStateKey.empty());
|
|
|
|
|
EXPECT_TRUE(afterRelease.captureStateKey.empty());
|
|
|
|
|
EXPECT_NE(afterRelease.focusedStateKey.find("/input-capture"), std::string::npos);
|
|
|
|
|
EXPECT_NE(afterRelease.hoveredStateKey.find("/input-route"), std::string::npos);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 12:50:55 +08:00
|
|
|
TEST(UIRuntimeTest, ScreenPlayerConsumeLastFrameReturnsDetachedPacketAndClearsBorrowedState) {
|
|
|
|
|
TempFileScope viewFile("xcui_runtime_consume_player", ".xcui", BuildViewMarkup("Runtime Consume"));
|
|
|
|
|
UIDocumentScreenHost host = {};
|
|
|
|
|
UIScreenPlayer player(host);
|
|
|
|
|
|
|
|
|
|
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.consume.player")));
|
|
|
|
|
|
|
|
|
|
const auto& firstFrame = player.Update(BuildInputState(2u));
|
|
|
|
|
ASSERT_TRUE(firstFrame.stats.documentLoaded);
|
|
|
|
|
EXPECT_EQ(firstFrame.stats.presentedFrameIndex, 2u);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(firstFrame.drawData, "Runtime Consume"));
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenFrameResult consumedFrame = player.ConsumeLastFrame();
|
|
|
|
|
EXPECT_TRUE(consumedFrame.stats.documentLoaded);
|
|
|
|
|
EXPECT_EQ(consumedFrame.stats.presentedFrameIndex, 2u);
|
|
|
|
|
EXPECT_EQ(consumedFrame.stats.drawListCount, consumedFrame.drawData.GetDrawListCount());
|
|
|
|
|
EXPECT_EQ(consumedFrame.stats.commandCount, consumedFrame.drawData.GetTotalCommandCount());
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(consumedFrame.drawData, "Runtime Consume"));
|
|
|
|
|
EXPECT_EQ(player.GetPresentedFrameCount(), 1u);
|
|
|
|
|
|
|
|
|
|
const auto& clearedFrame = player.GetLastFrame();
|
|
|
|
|
EXPECT_FALSE(clearedFrame.stats.documentLoaded);
|
|
|
|
|
EXPECT_EQ(clearedFrame.stats.presentedFrameIndex, 0u);
|
|
|
|
|
EXPECT_EQ(clearedFrame.drawData.GetDrawListCount(), 0u);
|
|
|
|
|
EXPECT_TRUE(clearedFrame.errorMessage.empty());
|
|
|
|
|
|
|
|
|
|
const auto& secondFrame = player.Update(BuildInputState(3u));
|
|
|
|
|
EXPECT_TRUE(secondFrame.stats.documentLoaded);
|
|
|
|
|
EXPECT_EQ(secondFrame.stats.presentedFrameIndex, 3u);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(secondFrame.drawData, "Runtime Consume"));
|
|
|
|
|
EXPECT_EQ(player.GetPresentedFrameCount(), 2u);
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(consumedFrame.stats.presentedFrameIndex, 2u);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(consumedFrame.drawData, "Runtime Consume"));
|
|
|
|
|
|
|
|
|
|
const XCEngine::UI::Runtime::UIScreenFrameResult emptyFrame = player.ConsumeLastFrame();
|
|
|
|
|
EXPECT_TRUE(emptyFrame.stats.documentLoaded);
|
|
|
|
|
EXPECT_EQ(emptyFrame.stats.presentedFrameIndex, 3u);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(emptyFrame.drawData, "Runtime Consume"));
|
|
|
|
|
|
|
|
|
|
const XCEngine::UI::Runtime::UIScreenFrameResult clearedAgain = player.ConsumeLastFrame();
|
|
|
|
|
EXPECT_FALSE(clearedAgain.stats.documentLoaded);
|
|
|
|
|
EXPECT_EQ(clearedAgain.stats.presentedFrameIndex, 0u);
|
|
|
|
|
EXPECT_EQ(clearedAgain.drawData.GetDrawListCount(), 0u);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
TEST(UIRuntimeTest, UISystemForwardsActiveScreenToPlayer) {
|
|
|
|
|
TempFileScope baseView("xcui_runtime_base", ".xcui", BuildViewMarkup("Base Screen"));
|
|
|
|
|
TempFileScope overlayView("xcui_runtime_overlay", ".xcui", BuildViewMarkup("Overlay Screen", "Modal Dialog"));
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
UIDocumentScreenHost host = {};
|
2026-04-05 05:14:16 +08:00
|
|
|
UISystem system(host);
|
|
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
const auto baseLayer = system.PushScreen(
|
|
|
|
|
BuildScreenAsset(baseView.Path(), "runtime.base"));
|
|
|
|
|
ASSERT_NE(baseLayer, 0u);
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
XCEngine::UI::Runtime::UIScreenLayerOptions overlayOptions = {};
|
|
|
|
|
overlayOptions.debugName = "overlay";
|
|
|
|
|
overlayOptions.blocksLayersBelow = true;
|
|
|
|
|
const auto overlayLayer = system.PushScreen(
|
|
|
|
|
BuildScreenAsset(overlayView.Path(), "runtime.overlay"),
|
|
|
|
|
overlayOptions);
|
|
|
|
|
ASSERT_NE(overlayLayer, 0u);
|
2026-04-05 05:14:16 +08:00
|
|
|
|
2026-04-05 05:44:07 +08:00
|
|
|
const auto& frame = system.Update(BuildInputState(3u));
|
2026-04-05 05:14:16 +08:00
|
|
|
EXPECT_EQ(frame.presentedLayerCount, 1u);
|
|
|
|
|
EXPECT_EQ(frame.skippedLayerCount, 1u);
|
2026-04-05 05:44:07 +08:00
|
|
|
EXPECT_EQ(frame.layers.size(), 1u);
|
|
|
|
|
EXPECT_EQ(frame.layers.front().layerId, overlayLayer);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Overlay Screen"));
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Modal Dialog"));
|
|
|
|
|
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Base Screen"));
|
2026-04-05 05:14:16 +08:00
|
|
|
}
|
2026-04-05 06:05:54 +08:00
|
|
|
|
|
|
|
|
TEST(UIRuntimeTest, ScreenStackControllerAppliesHudAndMenuLayerPolicies) {
|
|
|
|
|
TempFileScope hudView("xcui_runtime_hud", ".xcui", BuildViewMarkup("HUD Screen"));
|
|
|
|
|
TempFileScope menuView("xcui_runtime_menu", ".xcui", BuildViewMarkup("Pause Menu", "Paused"));
|
|
|
|
|
|
|
|
|
|
UIDocumentScreenHost host = {};
|
|
|
|
|
UISystem system(host);
|
|
|
|
|
UIScreenStackController stack(system);
|
|
|
|
|
|
|
|
|
|
const auto hudLayer = stack.PushHud(BuildScreenAsset(hudView.Path(), "runtime.hud"), "hud");
|
|
|
|
|
const auto menuLayer = stack.PushMenu(BuildScreenAsset(menuView.Path(), "runtime.menu"), "menu");
|
|
|
|
|
ASSERT_NE(hudLayer, 0u);
|
|
|
|
|
ASSERT_NE(menuLayer, 0u);
|
|
|
|
|
|
|
|
|
|
ASSERT_EQ(stack.GetEntryCount(), 2u);
|
|
|
|
|
ASSERT_NE(stack.GetTop(), nullptr);
|
|
|
|
|
EXPECT_EQ(stack.GetTop()->layerId, menuLayer);
|
|
|
|
|
|
|
|
|
|
const auto* hudOptions = system.FindLayerOptions(hudLayer);
|
|
|
|
|
const auto* menuOptions = system.FindLayerOptions(menuLayer);
|
|
|
|
|
ASSERT_NE(hudOptions, nullptr);
|
|
|
|
|
ASSERT_NE(menuOptions, nullptr);
|
|
|
|
|
EXPECT_FALSE(hudOptions->acceptsInput);
|
|
|
|
|
EXPECT_FALSE(hudOptions->blocksLayersBelow);
|
|
|
|
|
EXPECT_TRUE(menuOptions->acceptsInput);
|
|
|
|
|
EXPECT_TRUE(menuOptions->blocksLayersBelow);
|
|
|
|
|
|
|
|
|
|
const auto& frame = system.Update(BuildInputState(4u));
|
|
|
|
|
EXPECT_EQ(frame.presentedLayerCount, 1u);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Pause Menu"));
|
|
|
|
|
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "HUD Screen"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(UIRuntimeTest, ScreenStackControllerReplaceTopSwapsMenuContent) {
|
|
|
|
|
TempFileScope pauseView("xcui_runtime_pause", ".xcui", BuildViewMarkup("Pause Menu"));
|
|
|
|
|
TempFileScope settingsView("xcui_runtime_settings", ".xcui", BuildViewMarkup("Settings Menu"));
|
|
|
|
|
|
|
|
|
|
UIDocumentScreenHost host = {};
|
|
|
|
|
UISystem system(host);
|
|
|
|
|
UIScreenStackController stack(system);
|
|
|
|
|
|
|
|
|
|
const auto pauseLayer = stack.PushMenu(BuildScreenAsset(pauseView.Path(), "runtime.pause"), "pause");
|
|
|
|
|
ASSERT_NE(pauseLayer, 0u);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenLayerOptions replacementOptions = {};
|
|
|
|
|
replacementOptions.debugName = "settings";
|
|
|
|
|
replacementOptions.acceptsInput = true;
|
|
|
|
|
replacementOptions.blocksLayersBelow = true;
|
|
|
|
|
ASSERT_TRUE(stack.ReplaceTop(
|
|
|
|
|
BuildScreenAsset(settingsView.Path(), "runtime.settings"),
|
|
|
|
|
replacementOptions));
|
|
|
|
|
|
|
|
|
|
ASSERT_EQ(stack.GetEntryCount(), 1u);
|
|
|
|
|
ASSERT_NE(stack.GetTop(), nullptr);
|
|
|
|
|
EXPECT_EQ(stack.GetTop()->asset.screenId, "runtime.settings");
|
|
|
|
|
EXPECT_NE(stack.GetTop()->layerId, pauseLayer);
|
|
|
|
|
|
|
|
|
|
const auto& frame = system.Update(BuildInputState(5u));
|
|
|
|
|
EXPECT_EQ(frame.presentedLayerCount, 1u);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Settings Menu"));
|
|
|
|
|
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Pause Menu"));
|
|
|
|
|
}
|
2026-04-05 06:36:50 +08:00
|
|
|
|
|
|
|
|
TEST(UIRuntimeTest, ScreenStackControllerReplaceTopKeepsPreviousScreenWhenReplacementFails) {
|
|
|
|
|
TempFileScope pauseView("xcui_runtime_pause", ".xcui", BuildViewMarkup("Pause Menu"));
|
|
|
|
|
|
|
|
|
|
UIDocumentScreenHost host = {};
|
|
|
|
|
UISystem system(host);
|
|
|
|
|
UIScreenStackController stack(system);
|
|
|
|
|
|
|
|
|
|
const auto pauseLayer = stack.PushMenu(BuildScreenAsset(pauseView.Path(), "runtime.pause"), "pause");
|
|
|
|
|
ASSERT_NE(pauseLayer, 0u);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenLayerOptions replacementOptions = {};
|
|
|
|
|
replacementOptions.debugName = "broken";
|
|
|
|
|
replacementOptions.acceptsInput = true;
|
|
|
|
|
replacementOptions.blocksLayersBelow = true;
|
|
|
|
|
|
|
|
|
|
UIScreenAsset invalidAsset = {};
|
|
|
|
|
invalidAsset.screenId = "runtime.invalid";
|
|
|
|
|
invalidAsset.documentPath = (fs::temp_directory_path() / "xcui_missing_runtime_screen.xcui").string();
|
|
|
|
|
|
|
|
|
|
EXPECT_FALSE(stack.ReplaceTop(invalidAsset, replacementOptions));
|
|
|
|
|
ASSERT_EQ(stack.GetEntryCount(), 1u);
|
|
|
|
|
ASSERT_NE(stack.GetTop(), nullptr);
|
|
|
|
|
EXPECT_EQ(stack.GetTop()->layerId, pauseLayer);
|
|
|
|
|
EXPECT_EQ(stack.GetTop()->asset.screenId, "runtime.pause");
|
|
|
|
|
EXPECT_EQ(system.GetLayerCount(), 1u);
|
|
|
|
|
|
|
|
|
|
const auto& frame = system.Update(BuildInputState(6u));
|
|
|
|
|
EXPECT_EQ(frame.presentedLayerCount, 1u);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(frame.drawData, "Pause Menu"));
|
|
|
|
|
}
|
2026-04-05 12:50:55 +08:00
|
|
|
|
|
|
|
|
TEST(UIRuntimeTest, RoutesViewportAndFocusOnlyToTopInteractiveVisibleLayer) {
|
|
|
|
|
RecordingDocumentHost host = {};
|
|
|
|
|
UISystem system(host);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenLayerOptions gameplayOptions = {};
|
|
|
|
|
gameplayOptions.debugName = "gameplay";
|
|
|
|
|
gameplayOptions.acceptsInput = true;
|
|
|
|
|
gameplayOptions.blocksLayersBelow = false;
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenLayerOptions overlayOptions = {};
|
|
|
|
|
overlayOptions.debugName = "overlay";
|
|
|
|
|
overlayOptions.acceptsInput = true;
|
|
|
|
|
overlayOptions.blocksLayersBelow = false;
|
|
|
|
|
|
|
|
|
|
const auto gameplayLayerId = system.PushScreen(
|
|
|
|
|
BuildScreenAsset(fs::path("gameplay_view.xcui"), "runtime.gameplay"),
|
|
|
|
|
gameplayOptions);
|
|
|
|
|
const auto overlayLayerId = system.PushScreen(
|
|
|
|
|
BuildScreenAsset(fs::path("overlay_view.xcui"), "runtime.overlay"),
|
|
|
|
|
overlayOptions);
|
|
|
|
|
ASSERT_NE(gameplayLayerId, 0u);
|
|
|
|
|
ASSERT_NE(overlayLayerId, 0u);
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput input = BuildInputState(8u);
|
|
|
|
|
input.viewportRect = XCEngine::UI::UIRect(15.0f, 25.0f, 1024.0f, 576.0f);
|
|
|
|
|
XCEngine::UI::UIInputEvent textEvent = {};
|
|
|
|
|
textEvent.type = XCEngine::UI::UIInputEventType::Character;
|
|
|
|
|
textEvent.character = 'I';
|
|
|
|
|
input.events.push_back(textEvent);
|
|
|
|
|
|
|
|
|
|
const auto& frame = system.Update(input);
|
|
|
|
|
ASSERT_EQ(frame.presentedLayerCount, 2u);
|
|
|
|
|
ASSERT_EQ(frame.layers.size(), 2u);
|
|
|
|
|
ASSERT_EQ(host.GetBuildCallCount(), 2u);
|
|
|
|
|
|
|
|
|
|
const auto* gameplayCall = host.FindBuildCall("runtime.gameplay");
|
|
|
|
|
const auto* overlayCall = host.FindBuildCall("runtime.overlay");
|
|
|
|
|
ASSERT_NE(gameplayCall, nullptr);
|
|
|
|
|
ASSERT_NE(overlayCall, nullptr);
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(gameplayCall->input.viewportRect.x, input.viewportRect.x);
|
|
|
|
|
EXPECT_EQ(gameplayCall->input.viewportRect.y, input.viewportRect.y);
|
|
|
|
|
EXPECT_EQ(gameplayCall->input.viewportRect.width, input.viewportRect.width);
|
|
|
|
|
EXPECT_EQ(gameplayCall->input.viewportRect.height, input.viewportRect.height);
|
|
|
|
|
EXPECT_TRUE(gameplayCall->input.events.empty());
|
|
|
|
|
EXPECT_FALSE(gameplayCall->input.focused);
|
|
|
|
|
EXPECT_EQ(gameplayCall->input.frameIndex, input.frameIndex);
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(overlayCall->input.viewportRect.x, input.viewportRect.x);
|
|
|
|
|
EXPECT_EQ(overlayCall->input.viewportRect.y, input.viewportRect.y);
|
|
|
|
|
EXPECT_EQ(overlayCall->input.viewportRect.width, input.viewportRect.width);
|
|
|
|
|
EXPECT_EQ(overlayCall->input.viewportRect.height, input.viewportRect.height);
|
|
|
|
|
EXPECT_EQ(overlayCall->input.events.size(), 1u);
|
|
|
|
|
EXPECT_TRUE(overlayCall->input.focused);
|
|
|
|
|
EXPECT_EQ(overlayCall->input.frameIndex, input.frameIndex);
|
|
|
|
|
|
|
|
|
|
const auto* gameplayLayer = FindPresentedLayerById(frame, gameplayLayerId);
|
|
|
|
|
const auto* overlayLayer = FindPresentedLayerById(frame, overlayLayerId);
|
|
|
|
|
ASSERT_NE(gameplayLayer, nullptr);
|
|
|
|
|
ASSERT_NE(overlayLayer, nullptr);
|
|
|
|
|
EXPECT_EQ(gameplayLayer->stats.inputEventCount, 0u);
|
|
|
|
|
EXPECT_EQ(overlayLayer->stats.inputEventCount, 1u);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(UIRuntimeTest, HiddenTopLayerLeavesUnderlyingLayerFocusedAndInteractive) {
|
|
|
|
|
RecordingDocumentHost host = {};
|
|
|
|
|
UISystem system(host);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenLayerOptions visibleOptions = {};
|
|
|
|
|
visibleOptions.debugName = "visible";
|
|
|
|
|
visibleOptions.acceptsInput = true;
|
|
|
|
|
visibleOptions.blocksLayersBelow = false;
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenLayerOptions hiddenOptions = {};
|
|
|
|
|
hiddenOptions.debugName = "hidden";
|
|
|
|
|
hiddenOptions.visible = false;
|
|
|
|
|
hiddenOptions.acceptsInput = true;
|
|
|
|
|
hiddenOptions.blocksLayersBelow = false;
|
|
|
|
|
|
|
|
|
|
const auto visibleLayerId = system.PushScreen(
|
|
|
|
|
BuildScreenAsset(fs::path("visible_view.xcui"), "runtime.visible"),
|
|
|
|
|
visibleOptions);
|
|
|
|
|
const auto hiddenLayerId = system.PushScreen(
|
|
|
|
|
BuildScreenAsset(fs::path("hidden_view.xcui"), "runtime.hidden"),
|
|
|
|
|
hiddenOptions);
|
|
|
|
|
ASSERT_NE(visibleLayerId, 0u);
|
|
|
|
|
ASSERT_NE(hiddenLayerId, 0u);
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput input = BuildInputState(9u);
|
|
|
|
|
input.viewportRect = XCEngine::UI::UIRect(40.0f, 60.0f, 700.0f, 420.0f);
|
|
|
|
|
XCEngine::UI::UIInputEvent keyEvent = {};
|
|
|
|
|
keyEvent.type = XCEngine::UI::UIInputEventType::KeyDown;
|
|
|
|
|
keyEvent.keyCode = 32;
|
|
|
|
|
input.events.push_back(keyEvent);
|
|
|
|
|
|
|
|
|
|
const auto& frame = system.Update(input);
|
|
|
|
|
ASSERT_EQ(frame.presentedLayerCount, 1u);
|
|
|
|
|
ASSERT_EQ(frame.skippedLayerCount, 1u);
|
|
|
|
|
ASSERT_EQ(frame.layers.size(), 1u);
|
|
|
|
|
ASSERT_EQ(host.GetBuildCallCount(), 1u);
|
|
|
|
|
|
|
|
|
|
const auto* visibleCall = host.FindBuildCall("runtime.visible");
|
|
|
|
|
ASSERT_NE(visibleCall, nullptr);
|
|
|
|
|
EXPECT_EQ(visibleCall->input.viewportRect.x, input.viewportRect.x);
|
|
|
|
|
EXPECT_EQ(visibleCall->input.viewportRect.y, input.viewportRect.y);
|
|
|
|
|
EXPECT_EQ(visibleCall->input.viewportRect.width, input.viewportRect.width);
|
|
|
|
|
EXPECT_EQ(visibleCall->input.viewportRect.height, input.viewportRect.height);
|
|
|
|
|
EXPECT_EQ(visibleCall->input.events.size(), 1u);
|
|
|
|
|
EXPECT_TRUE(visibleCall->input.focused);
|
|
|
|
|
EXPECT_EQ(host.FindBuildCall("runtime.hidden"), nullptr);
|
|
|
|
|
|
|
|
|
|
const auto* visibleLayer = FindPresentedLayerById(frame, visibleLayerId);
|
|
|
|
|
ASSERT_NE(visibleLayer, nullptr);
|
|
|
|
|
EXPECT_EQ(visibleLayer->stats.inputEventCount, 1u);
|
|
|
|
|
EXPECT_EQ(FindPresentedLayerById(frame, hiddenLayerId), nullptr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(UIRuntimeTest, UISystemConsumeLastFrameReturnsDetachedPresentationPacket) {
|
|
|
|
|
RecordingDocumentHost host = {};
|
|
|
|
|
UISystem system(host);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UIScreenLayerOptions options = {};
|
|
|
|
|
options.debugName = "runtime";
|
|
|
|
|
const auto layerId = system.PushScreen(
|
|
|
|
|
BuildScreenAsset(fs::path("runtime_consume_view.xcui"), "runtime.consume"),
|
|
|
|
|
options);
|
|
|
|
|
ASSERT_NE(layerId, 0u);
|
|
|
|
|
|
|
|
|
|
UIScreenFrameInput input = BuildInputState(12u);
|
|
|
|
|
input.viewportRect = XCEngine::UI::UIRect(48.0f, 72.0f, 1280.0f, 720.0f);
|
|
|
|
|
input.deltaTimeSeconds = 1.0 / 30.0;
|
|
|
|
|
XCEngine::UI::UIInputEvent textEvent = {};
|
|
|
|
|
textEvent.type = XCEngine::UI::UIInputEventType::Character;
|
|
|
|
|
textEvent.character = 'R';
|
|
|
|
|
input.events.push_back(textEvent);
|
|
|
|
|
|
|
|
|
|
const auto& frame = system.Update(input);
|
|
|
|
|
ASSERT_EQ(frame.presentedLayerCount, 1u);
|
|
|
|
|
EXPECT_EQ(frame.viewportRect.x, input.viewportRect.x);
|
|
|
|
|
EXPECT_EQ(frame.viewportRect.y, input.viewportRect.y);
|
|
|
|
|
EXPECT_EQ(frame.viewportRect.width, input.viewportRect.width);
|
|
|
|
|
EXPECT_EQ(frame.viewportRect.height, input.viewportRect.height);
|
|
|
|
|
EXPECT_EQ(frame.submittedInputEventCount, 1u);
|
|
|
|
|
EXPECT_DOUBLE_EQ(frame.deltaTimeSeconds, input.deltaTimeSeconds);
|
|
|
|
|
EXPECT_TRUE(frame.focused);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UISystemFrameResult consumedFrame = system.ConsumeLastFrame();
|
|
|
|
|
EXPECT_EQ(consumedFrame.frameIndex, input.frameIndex);
|
|
|
|
|
EXPECT_EQ(consumedFrame.presentedLayerCount, 1u);
|
|
|
|
|
EXPECT_EQ(consumedFrame.layers.size(), 1u);
|
|
|
|
|
EXPECT_EQ(consumedFrame.layers.front().layerId, layerId);
|
|
|
|
|
EXPECT_EQ(consumedFrame.viewportRect.x, input.viewportRect.x);
|
|
|
|
|
EXPECT_EQ(consumedFrame.viewportRect.y, input.viewportRect.y);
|
|
|
|
|
EXPECT_EQ(consumedFrame.viewportRect.width, input.viewportRect.width);
|
|
|
|
|
EXPECT_EQ(consumedFrame.viewportRect.height, input.viewportRect.height);
|
|
|
|
|
EXPECT_EQ(consumedFrame.submittedInputEventCount, 1u);
|
|
|
|
|
EXPECT_DOUBLE_EQ(consumedFrame.deltaTimeSeconds, input.deltaTimeSeconds);
|
|
|
|
|
EXPECT_TRUE(consumedFrame.focused);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(consumedFrame.drawData, "runtime.consume"));
|
|
|
|
|
|
|
|
|
|
const auto& clearedFrame = system.GetLastFrame();
|
|
|
|
|
EXPECT_EQ(clearedFrame.frameIndex, 0u);
|
|
|
|
|
EXPECT_EQ(clearedFrame.presentedLayerCount, 0u);
|
|
|
|
|
EXPECT_EQ(clearedFrame.submittedInputEventCount, 0u);
|
|
|
|
|
EXPECT_TRUE(clearedFrame.layers.empty());
|
|
|
|
|
EXPECT_EQ(clearedFrame.drawData.GetDrawListCount(), 0u);
|
|
|
|
|
|
|
|
|
|
const XCEngine::UI::Runtime::UISystemFrameResult emptyFrame = system.ConsumeLastFrame();
|
|
|
|
|
EXPECT_EQ(emptyFrame.frameIndex, 0u);
|
|
|
|
|
EXPECT_TRUE(emptyFrame.layers.empty());
|
|
|
|
|
EXPECT_EQ(emptyFrame.drawData.GetDrawListCount(), 0u);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(UIRuntimeTest, SceneRuntimeContextConsumeLastFrameForwardsPresentationSnapshot) {
|
|
|
|
|
TempFileScope viewFile("xcui_runtime_context", ".xcui", BuildViewMarkup("Runtime Context"));
|
|
|
|
|
UISceneRuntimeContext runtimeContext = {};
|
|
|
|
|
|
|
|
|
|
const auto layerId = runtimeContext.GetStackController().PushMenu(
|
|
|
|
|
BuildScreenAsset(viewFile.Path(), "runtime.context"),
|
|
|
|
|
"context");
|
|
|
|
|
ASSERT_NE(layerId, 0u);
|
|
|
|
|
|
|
|
|
|
const XCEngine::UI::UIRect viewportRect(24.0f, 32.0f, 960.0f, 540.0f);
|
|
|
|
|
runtimeContext.SetViewportRect(viewportRect);
|
|
|
|
|
runtimeContext.SetFocused(true);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::UIInputEvent textEvent = {};
|
|
|
|
|
textEvent.type = XCEngine::UI::UIInputEventType::Character;
|
|
|
|
|
textEvent.character = 'C';
|
|
|
|
|
runtimeContext.QueueInputEvent(textEvent);
|
|
|
|
|
|
|
|
|
|
runtimeContext.Update(0.25);
|
|
|
|
|
|
|
|
|
|
XCEngine::UI::Runtime::UISystemFrameResult consumedFrame = runtimeContext.ConsumeLastFrame();
|
|
|
|
|
EXPECT_EQ(consumedFrame.frameIndex, 1u);
|
|
|
|
|
EXPECT_EQ(consumedFrame.presentedLayerCount, 1u);
|
|
|
|
|
EXPECT_EQ(consumedFrame.layers.size(), 1u);
|
|
|
|
|
EXPECT_EQ(consumedFrame.layers.front().layerId, layerId);
|
|
|
|
|
EXPECT_EQ(consumedFrame.viewportRect.x, viewportRect.x);
|
|
|
|
|
EXPECT_EQ(consumedFrame.viewportRect.y, viewportRect.y);
|
|
|
|
|
EXPECT_EQ(consumedFrame.viewportRect.width, viewportRect.width);
|
|
|
|
|
EXPECT_EQ(consumedFrame.viewportRect.height, viewportRect.height);
|
|
|
|
|
EXPECT_EQ(consumedFrame.submittedInputEventCount, 1u);
|
|
|
|
|
EXPECT_DOUBLE_EQ(consumedFrame.deltaTimeSeconds, 0.25);
|
|
|
|
|
EXPECT_TRUE(consumedFrame.focused);
|
|
|
|
|
EXPECT_TRUE(DrawDataContainsText(consumedFrame.drawData, "Runtime Context"));
|
|
|
|
|
|
|
|
|
|
const auto& clearedFrame = runtimeContext.GetLastFrame();
|
|
|
|
|
EXPECT_EQ(clearedFrame.frameIndex, 0u);
|
|
|
|
|
EXPECT_EQ(clearedFrame.presentedLayerCount, 0u);
|
|
|
|
|
EXPECT_TRUE(clearedFrame.layers.empty());
|
|
|
|
|
EXPECT_EQ(clearedFrame.drawData.GetDrawListCount(), 0u);
|
|
|
|
|
|
|
|
|
|
runtimeContext.Update(0.5);
|
|
|
|
|
const auto& secondFrame = runtimeContext.GetLastFrame();
|
|
|
|
|
EXPECT_EQ(secondFrame.frameIndex, 2u);
|
|
|
|
|
EXPECT_EQ(secondFrame.submittedInputEventCount, 0u);
|
|
|
|
|
EXPECT_DOUBLE_EQ(secondFrame.deltaTimeSeconds, 0.5);
|
|
|
|
|
EXPECT_TRUE(secondFrame.focused);
|
|
|
|
|
}
|