Add XCUI input state validation sandbox batch

This commit is contained in:
2026-04-05 22:35:24 +08:00
parent 1908e81e5c
commit 5342e447af
7 changed files with 376 additions and 133 deletions

View File

@@ -8,6 +8,7 @@ set(UI_TEST_SOURCES
test_ui_editor_panel_chrome.cpp
test_ui_expansion_model.cpp
test_ui_flat_hierarchy_helpers.cpp
test_ui_input_dispatcher.cpp
test_ui_keyboard_navigation_model.cpp
test_ui_property_edit_model.cpp
test_layout_engine.cpp

View File

@@ -0,0 +1,110 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Input/UIInputDispatcher.h>
#include <algorithm>
#include <vector>
namespace {
using XCEngine::UI::UIInputDispatchRequest;
using XCEngine::UI::UIInputDispatcher;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIInputPath;
using XCEngine::UI::UIPointerButton;
UIInputEvent MakePointerEvent(
UIInputEventType type,
UIPointerButton button = UIPointerButton::None) {
UIInputEvent event = {};
event.type = type;
event.pointerButton = button;
return event;
}
} // namespace
TEST(UIInputDispatcherTest, PointerDownTransfersFocusAndStartsActivePath) {
UIInputDispatcher dispatcher{};
const UIInputPath hoveredPath = { 10u, 20u, 30u };
std::vector<UIInputDispatchRequest> routedRequests = {};
const auto summary = dispatcher.Dispatch(
MakePointerEvent(UIInputEventType::PointerButtonDown, UIPointerButton::Left),
hoveredPath,
[&](const UIInputDispatchRequest& request) {
routedRequests.push_back(request);
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_TRUE(summary.focusChange.Changed());
EXPECT_EQ(summary.focusChange.previousPath, UIInputPath());
EXPECT_EQ(summary.focusChange.currentPath, hoveredPath);
EXPECT_EQ(dispatcher.GetFocusController().GetFocusedPath(), hoveredPath);
EXPECT_EQ(dispatcher.GetFocusController().GetActivePath(), hoveredPath);
ASSERT_FALSE(routedRequests.empty());
EXPECT_EQ(summary.routing.plan.targetPath, hoveredPath);
EXPECT_EQ(summary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Hovered);
const auto targetIt = std::find_if(
routedRequests.begin(),
routedRequests.end(),
[](const UIInputDispatchRequest& request) {
return request.isTargetElement;
});
ASSERT_NE(targetIt, routedRequests.end());
EXPECT_EQ(targetIt->elementId, hoveredPath.Target());
}
TEST(UIInputDispatcherTest, PointerCaptureOverridesHoveredRouteForPointerEvents) {
UIInputDispatcher dispatcher{};
const UIInputPath hoveredPath = { 41u, 42u };
const UIInputPath capturePath = { 7u, 8u, 9u };
dispatcher.GetFocusController().SetPointerCapturePath(capturePath);
const auto summary = dispatcher.Dispatch(
MakePointerEvent(UIInputEventType::PointerMove),
hoveredPath,
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_EQ(summary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Captured);
EXPECT_EQ(summary.routing.plan.targetPath, capturePath);
}
TEST(UIInputDispatcherTest, PointerButtonUpClearsActivePathAfterDispatch) {
UIInputDispatcher dispatcher{};
const UIInputPath activePath = { 2u, 4u, 6u };
dispatcher.GetFocusController().SetActivePath(activePath);
const auto summary = dispatcher.Dispatch(
MakePointerEvent(UIInputEventType::PointerButtonUp, UIPointerButton::Left),
{},
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_FALSE(summary.routing.handled);
EXPECT_FALSE(dispatcher.GetFocusController().HasActivePath());
}
TEST(UIInputDispatcherTest, KeyboardEventsRouteToFocusedPath) {
UIInputDispatcher dispatcher{};
const UIInputPath focusedPath = { 101u, 202u };
dispatcher.GetFocusController().SetFocusedPath(focusedPath);
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = 'F';
const auto summary = dispatcher.Dispatch(
event,
{},
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_EQ(summary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Focused);
EXPECT_EQ(summary.routing.plan.targetPath, focusedPath);
}

View File

@@ -153,6 +153,21 @@ bool TryFindSmallestFilledRectContainingPoint(
return found;
}
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);
}
std::size_t CountCommandsOfType(
const XCEngine::UI::UIDrawData& drawData,
XCEngine::UI::UIDrawCommandType type) {
@@ -384,6 +399,122 @@ TEST(UIRuntimeTest, DocumentHostScrollViewClipsOverflowingChildrenAndRespondsToW
EXPECT_FLOAT_EQ(line01Persisted->position.y, line01AfterY);
}
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);
}
TEST(UIRuntimeTest, ScreenPlayerConsumeLastFrameReturnsDetachedPacketAndClearsBorrowedState) {
TempFileScope viewFile("xcui_runtime_consume_player", ".xcui", BuildViewMarkup("Runtime Consume"));
UIDocumentScreenHost host = {};