Files
InstanceBrick--nightcap/tests/roblox_input_router_test.cc
T
vii b60c43e8f0 chore(merge): merge upstream komaruworld/mocktail main
Keep both the CallProtocol and AccountProtocol bridges. Keep our pointer
hold and catalog validation. Re-apply our JNI local-frame counting,
singleton handles, class handle cache, handle-table report and thread
detach release on upstream's split jnivm files.
2026-10-01 21:41:43 -05:00

898 lines
35 KiB
C++

#include "runtime/roblox_input_router.h"
#include <SDL3/SDL_events.h>
#include <SDL3/SDL_keycode.h>
#include <SDL3/SDL_mouse.h>
#include <SDL3/SDL_scancode.h>
#include <gtest/gtest.h>
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#include "mocktail/platform/sdl_event_converter.h"
namespace mocktail {
namespace runtime {
namespace {
struct MouseMoveCall {
float x = 0.0f;
float y = 0.0f;
float delta_x = 0.0f;
float delta_y = 0.0f;
};
struct MouseButtonCall {
float x = 0.0f;
float y = 0.0f;
bool pressed = false;
int32_t button = -1;
};
struct MouseWheelCall {
float x = 0.0f;
float y = 0.0f;
float delta_y = 0.0f;
};
struct TouchCall {
int32_t pointer_id = -1;
float x = 0.0f;
float y = 0.0f;
int32_t action = -1;
int32_t width = 0;
int32_t height = 0;
};
struct KeyCall {
bool pressed = false;
int32_t scan_code = 0;
int32_t key_code = 0;
bool repeat = false;
};
struct Probe {
std::vector<MouseMoveCall> mouse_moves;
std::vector<MouseButtonCall> mouse_buttons;
std::vector<MouseWheelCall> mouse_wheels;
std::vector<TouchCall> touches;
std::vector<KeyCall> keys;
std::vector<std::string> text_operations;
std::vector<std::string> ordered_operations;
Status next_status = Status::Ok();
size_t CallCount() const {
return mouse_moves.size() + mouse_buttons.size() + mouse_wheels.size() +
touches.size() + keys.size() + text_operations.size();
}
};
class ClipboardProbe final : public platform::TextClipboard {
public:
Status ReadText(std::string* text) override {
++read_calls;
*text = value;
return Status::Ok();
}
Status WriteText(const std::string& text) override {
value = text;
return Status::Ok();
}
std::string value;
int read_calls = 0;
};
Status ConsumeStatus(Probe* probe) {
Status status = probe->next_status;
probe->next_status = Status::Ok();
return status;
}
Status MouseMove(void* context, float x, float y, float delta_x,
float delta_y) {
auto* probe = static_cast<Probe*>(context);
probe->mouse_moves.push_back({x, y, delta_x, delta_y});
return ConsumeStatus(probe);
}
Status MouseButton(void* context, float x, float y, bool pressed,
int32_t button) {
auto* probe = static_cast<Probe*>(context);
probe->mouse_buttons.push_back({x, y, pressed, button});
return ConsumeStatus(probe);
}
Status MouseWheel(void* context, float x, float y, float delta_y) {
auto* probe = static_cast<Probe*>(context);
probe->mouse_wheels.push_back({x, y, delta_y});
return ConsumeStatus(probe);
}
Status Touch(void* context, int32_t pointer_id, float x, float y,
int32_t action, int32_t width, int32_t height) {
auto* probe = static_cast<Probe*>(context);
probe->touches.push_back({pointer_id, x, y, action, width, height});
return ConsumeStatus(probe);
}
Status Key(void* context, bool pressed, int32_t scan_code, int32_t key_code,
bool repeat) {
auto* probe = static_cast<Probe*>(context);
probe->keys.push_back({pressed, scan_code, key_code, repeat});
probe->ordered_operations.push_back(pressed ? "key-down" : "key-up");
return ConsumeStatus(probe);
}
Status SyncText(void* context, const char*, std::size_t, int32_t) {
auto* probe = static_cast<Probe*>(context);
probe->text_operations.push_back("sync");
probe->ordered_operations.push_back("sync");
return ConsumeStatus(probe);
}
Status PassText(void* context, int64_t, const char*, std::size_t, bool,
int32_t) {
auto* probe = static_cast<Probe*>(context);
probe->text_operations.push_back("pass");
probe->ordered_operations.push_back("pass");
return ConsumeStatus(probe);
}
Status TextHandle(void* context, int64_t) {
auto* probe = static_cast<Probe*>(context);
probe->text_operations.push_back("handle");
probe->ordered_operations.push_back("handle");
return ConsumeStatus(probe);
}
RobloxInputSink Sink(Probe* probe) {
RobloxInputSink sink = {probe, MouseMove, MouseButton, MouseWheel,
Touch, Key, {}};
sink.text = {probe, SyncText, PassText, TextHandle, TextHandle};
return sink;
}
platform::PlatformEvent Event(platform::PlatformEventPayload payload) {
return {123, std::move(payload)};
}
class RobloxInputRouterTest : public ::testing::Test {
protected:
void SetUp() override {
ASSERT_TRUE(router_.Activate({1280, 720, 2560, 1440, 2.0F}, true).ok());
}
Probe probe_;
RobloxInputRouter router_{Sink(&probe_)};
};
TEST(RobloxInputMappingTest, MapsMouseButtonsForNativeInput) {
EXPECT_EQ(MapSdlMouseButtonToRoblox(SDL_BUTTON_LEFT), 0);
EXPECT_EQ(MapSdlMouseButtonToRoblox(SDL_BUTTON_RIGHT), 1);
EXPECT_EQ(MapSdlMouseButtonToRoblox(SDL_BUTTON_MIDDLE), 2);
EXPECT_EQ(MapSdlMouseButtonToRoblox(SDL_BUTTON_X1), 7);
EXPECT_EQ(MapSdlMouseButtonToRoblox(SDL_BUTTON_X2), 15);
EXPECT_EQ(MapSdlMouseButtonToRoblox(42), -1);
}
TEST(RobloxInputMappingTest, MapsSdlUsbKeysToLinuxAndAndroidCodes) {
EXPECT_EQ(MapSdlKeyToAndroid(SDL_SCANCODE_W).scan_code, 17);
EXPECT_EQ(MapSdlKeyToAndroid(SDL_SCANCODE_W).key_code, 51);
EXPECT_EQ(MapSdlKeyToAndroid(SDL_SCANCODE_SPACE).scan_code, 57);
EXPECT_EQ(MapSdlKeyToAndroid(SDL_SCANCODE_SPACE).key_code, 62);
EXPECT_EQ(MapSdlKeyToAndroid(SDL_SCANCODE_LSHIFT).scan_code, 42);
EXPECT_EQ(MapSdlKeyToAndroid(SDL_SCANCODE_LSHIFT).key_code, 59);
EXPECT_FALSE(MapSdlKeyToAndroid(SDL_SCANCODE_UNKNOWN).valid());
}
TEST(SurfaceCoordinateTransformTest,
KeepsAndroidDensityAndDrawableScaleAsInverseOperations) {
const platform::SurfaceCoordinateTransform retina = {1280, 720, 2560, 1440,
2.0F};
ASSERT_TRUE(retina.valid());
EXPECT_EQ(retina.rounded_guest_width(), 1280);
EXPECT_EQ(retina.rounded_guest_height(), 720);
EXPECT_FLOAT_EQ(retina.HostLogicalToGuestX(321.0F), 321.0F);
EXPECT_FLOAT_EQ(retina.GuestToHostLogicalY(123.0F), 123.0F);
const platform::SurfaceCoordinateTransform x11 = {1624, 811, 1624, 811,
1.145833F};
const float host_x = x11.GuestToHostLogicalX(943.0F);
const float host_y = x11.GuestToHostLogicalY(527.0F);
EXPECT_NEAR(host_x, 1080.5F, 0.1F);
EXPECT_NEAR(host_y, 603.85F, 0.1F);
EXPECT_NEAR(x11.HostLogicalToGuestX(host_x), 943.0F, 0.01F);
EXPECT_NEAR(x11.HostLogicalToGuestY(host_y), 527.0F, 0.01F);
}
TEST_F(RobloxInputRouterTest, RoutesMouseMotionButtonAndVerticalWheel) {
EXPECT_TRUE(router_
.HandleEvent(Event(platform::MouseMotionEvent{
100.0f, 80.0f, 5.0f, -3.0f, 0}))
.dispatched());
EXPECT_TRUE(router_
.HandleEvent(Event(platform::MouseButtonEvent{
true, SDL_BUTTON_RIGHT, 1, 100.0f, 80.0f}))
.dispatched());
EXPECT_TRUE(router_
.HandleEvent(Event(
platform::MouseWheelEvent{0.0f, -2.0f, -4.0f, -5.0f}))
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].x, 100.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].y, 80.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_x, 5.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_y, -3.0f);
ASSERT_EQ(probe_.mouse_buttons.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 100.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 80.0f);
EXPECT_EQ(probe_.mouse_buttons[0].button, 1);
ASSERT_EQ(probe_.mouse_wheels.size(), 1U);
// A wheel event without a usable position keeps the pointer where it was.
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].x, 100.0f);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].y, 80.0f);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].delta_y, -2.0f);
}
TEST_F(RobloxInputRouterTest, HoldsPointerPositionDuringRelativeMotion) {
// Relative-mode motion reports zero absolute coordinates. The pointer stays
// where it was captured while the deltas pass through, so a long camera
// turn neither walks the reported position to the viewport edge nor
// swallows the reversal. The next absolute event moves the pointer again.
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{640.0f, 360.0f, 0.0f,
0.0f, 0}))
.dispatched());
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 2000.0f,
0.0f, 0}))
.dispatched());
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, -100.0f,
40.0f, 0}))
.dispatched());
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{200.0f, 100.0f, 3.0f,
3.0f, 0}))
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 4U);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 640.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].y, 360.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].delta_x, 2000.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].x, 640.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].y, 360.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].delta_x, -100.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].delta_y, 40.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[3].x, 200.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[3].y, 100.0f);
}
TEST_F(RobloxInputRouterTest, HoldsPointerWhenRelativeMotionCarriesAPosition) {
// Wayland reports a drifting absolute position during relative mode instead
// of zeroes, and it can leave the window. The capture flag decides, not the
// coordinates, so the held position survives the drift and the reversal.
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseMotionEvent{
640.0f, 360.0f, 0.0f, 0.0f, 0, false}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseMotionEvent{
1840.0f, 470.0f, 1200.0f, 110.0f, 0, true}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseMotionEvent{
-510.0f, 430.0f, -2350.0f, -40.0f, 0, true}))
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 3U);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 640.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].y, 360.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].delta_x, 1200.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].x, 640.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].y, 360.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[2].delta_x, -2350.0f);
}
TEST_F(RobloxInputRouterTest, HoldsPointerWhenRelativeButtonCarriesAPosition) {
// The release that ends a Wayland camera drag carries the drifted position
// too. It must not move the pointer Roblox draws.
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseMotionEvent{
640.0f, 360.0f, 0.0f, 0.0f, 0, false}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseButtonEvent{
false, SDL_BUTTON_RIGHT, 1, 1840.0f, 470.0f, true}))
.dispatched());
ASSERT_FALSE(probe_.mouse_buttons.empty());
EXPECT_FLOAT_EQ(probe_.mouse_buttons.back().x, 640.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons.back().y, 360.0f);
}
TEST_F(RobloxInputRouterTest, HoldsPointerWhenRelativeWheelCarriesAPosition) {
// Zooming the camera mid-drag scrolls while capture is held. Wayland stamps
// the drifted position on the wheel event too, so the capture flag decides
// there as well.
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseMotionEvent{
640.0f, 360.0f, 0.0f, 0.0f, 0, false}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseWheelEvent{
0.0f, -1.0f, 1840.0f, 470.0f, true}))
.dispatched());
ASSERT_FALSE(probe_.mouse_wheels.empty());
EXPECT_FLOAT_EQ(probe_.mouse_wheels.back().x, 640.0f);
EXPECT_FLOAT_EQ(probe_.mouse_wheels.back().y, 360.0f);
}
TEST_F(RobloxInputRouterTest,
AnchorsUnknownPointerAtViewportCenterForRelativeMotion) {
// No absolute position has been seen yet, so the held position is the
// viewport center rather than the top-left corner.
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 25.0f,
-10.0f, 0}))
.dispatched());
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 300.0f,
0.0f, 0}))
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 2U);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].x, 639.5f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].y, 359.5f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_x, 25.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_y, -10.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 639.5f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].y, 359.5f);
}
TEST_F(RobloxInputRouterTest, ClampsHeldPointerToViewportAfterResize) {
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{1200.0f, 700.0f, 0.0f,
0.0f, 0}))
.dispatched());
ASSERT_EQ(router_
.HandleEvent(Event(
platform::WindowResizedEvent{800, 600, 800, 600, 1.0F}))
.state,
RobloxInputDispatchState::kStateUpdated);
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 50.0f,
0.0f, 0}))
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 2U);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 799.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].y, 599.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].delta_x, 50.0f);
}
TEST_F(RobloxInputRouterTest,
KeepsHeldPointerAcrossZeroCoordinateWheelAndButtonEvents) {
// While the pointer is captured, wheel and button events also arrive with
// zero coordinates. None of them may move the held position.
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{640.0f, 360.0f, 0.0f,
0.0f, 0}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(
platform::MouseWheelEvent{0.0f, -1.0f, 0.0f, 0.0f}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseButtonEvent{
true, SDL_BUTTON_LEFT, 1, 0.0f, 0.0f}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseButtonEvent{
false, SDL_BUTTON_LEFT, 1, 0.0f, 0.0f}))
.dispatched());
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, -500.0f,
0.0f, 0}))
.dispatched());
ASSERT_EQ(probe_.mouse_wheels.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].x, 640.0f);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].y, 360.0f);
ASSERT_EQ(probe_.mouse_buttons.size(), 2U);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 640.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 360.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[1].x, 640.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[1].y, 360.0f);
ASSERT_EQ(probe_.mouse_moves.size(), 2U);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].x, 640.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[1].y, 360.0f);
}
// A fractionally scaled X11 desktop: the buffer is 1:1 with logical units
// while the display scale is not, which is the case where the guest-surface
// scale actually changes a look delta.
constexpr platform::WindowResizedEvent kFractionalScalingViewport{
1624, 811, 1624, 811, 1.145833F};
TEST_F(RobloxInputRouterTest, ScalesLookDeltasUnlessRawMouseIsEnabled) {
ASSERT_EQ(router_.HandleEvent(Event(kFractionalScalingViewport)).state,
RobloxInputDispatchState::kStateUpdated);
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 10.0f,
-6.0f, 0}))
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 1U);
EXPECT_NEAR(probe_.mouse_moves[0].delta_x, 8.727f, 0.01f);
EXPECT_NEAR(probe_.mouse_moves[0].delta_y, -5.236f, 0.01f);
}
TEST_F(RobloxInputRouterTest, RawMouseSendsLookDeltasUnscaled) {
ASSERT_EQ(router_.HandleEvent(Event(kFractionalScalingViewport)).state,
RobloxInputDispatchState::kStateUpdated);
router_.SetRawMouseEnabled(true);
ASSERT_TRUE(
router_
.HandleEvent(Event(platform::MouseMotionEvent{0.0f, 0.0f, 10.0f,
-6.0f, 0}))
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_x, 10.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_y, -6.0f);
// The reported position stays in guest units whatever the delta carries:
// with no absolute position seen, it is the guest viewport center.
const platform::SurfaceCoordinateTransform transform = {1624, 811, 1624, 811,
1.145833F};
EXPECT_NEAR(probe_.mouse_moves[0].x, (transform.guest_width() - 1.0F) / 2.0F,
0.01f);
EXPECT_NEAR(probe_.mouse_moves[0].y,
(transform.guest_height() - 1.0F) / 2.0F, 0.01f);
}
TEST_F(RobloxInputRouterTest, MiddleClickUsesMouseButton3OnPressAndRelease) {
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseButtonEvent{
true, SDL_BUTTON_MIDDLE, 1, 100.0f, 80.0f}))
.dispatched());
EXPECT_EQ(router_.Snapshot().active_mouse_buttons, 1U);
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseButtonEvent{
false, SDL_BUTTON_MIDDLE, 1, 100.0f, 80.0f}))
.dispatched());
ASSERT_EQ(probe_.mouse_buttons.size(), 2U);
EXPECT_TRUE(probe_.mouse_buttons[0].pressed);
EXPECT_EQ(probe_.mouse_buttons[0].button, 2);
EXPECT_FALSE(probe_.mouse_buttons[1].pressed);
EXPECT_EQ(probe_.mouse_buttons[1].button, 2);
EXPECT_EQ(router_.Snapshot().active_mouse_buttons, 0U);
}
TEST_F(RobloxInputRouterTest,
ScalesMouseCoordinatesAfterDisplayScaleChange) {
const RobloxInputDispatchResult resize = router_.HandleEvent(
Event(platform::WindowResizedEvent{1000, 800, 1250, 1000, 1.25F}));
ASSERT_EQ(resize.state, RobloxInputDispatchState::kStateUpdated);
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseMotionEvent{
400.0f, 240.0f, 8.0f, -4.0f, 0}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseButtonEvent{
true, SDL_BUTTON_LEFT, 1, 300.0f, 200.0f}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(
platform::MouseWheelEvent{0.0f, 2.0f, 600.0f, 400.0f}))
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].x, 400.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].y, 240.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_x, 8.0f);
EXPECT_FLOAT_EQ(probe_.mouse_moves[0].delta_y, -4.0f);
ASSERT_EQ(probe_.mouse_buttons.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 300.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 200.0f);
ASSERT_EQ(probe_.mouse_wheels.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].x, 600.0f);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].y, 400.0f);
EXPECT_FLOAT_EQ(probe_.mouse_wheels[0].delta_y, 2.0f);
}
TEST_F(RobloxInputRouterTest,
DividesPhysicalPointerCoordinatesByAndroidDensity) {
const RobloxInputDispatchResult resize = router_.HandleEvent(
Event(platform::WindowResizedEvent{1624, 811, 1624, 811, 1.145833F}));
ASSERT_EQ(resize.state, RobloxInputDispatchState::kStateUpdated);
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseMotionEvent{
1080.5F, 604.0F, 11.45833F, -5.729165F, 0}))
.dispatched());
ASSERT_EQ(probe_.mouse_moves.size(), 1U);
EXPECT_NEAR(probe_.mouse_moves[0].x, 943.0F, 0.1F);
EXPECT_NEAR(probe_.mouse_moves[0].y, 527.14F, 0.1F);
EXPECT_NEAR(probe_.mouse_moves[0].delta_x, 10.0F, 0.01F);
EXPECT_NEAR(probe_.mouse_moves[0].delta_y, -5.0F, 0.01F);
}
TEST_F(RobloxInputRouterTest, ScalesNormalizedTouchAndKeepsStablePointerIds) {
EXPECT_TRUE(router_
.HandleEvent(Event(platform::TouchEvent{
platform::TouchEvent::Action::kDown, 10, 1001, 0.25f,
0.5f, 0.0f, 0.0f, 1.0f}))
.dispatched());
EXPECT_TRUE(router_
.HandleEvent(Event(platform::TouchEvent{
platform::TouchEvent::Action::kDown, 10, 9000000000LL,
0.75f, 0.25f, 0.0f, 0.0f, 1.0f}))
.dispatched());
EXPECT_TRUE(router_
.HandleEvent(Event(platform::TouchEvent{
platform::TouchEvent::Action::kMove, 10, 1001, 0.5f, 1.0f,
0.25f, 0.5f, 1.0f}))
.dispatched());
EXPECT_TRUE(router_
.HandleEvent(Event(platform::TouchEvent{
platform::TouchEvent::Action::kUp, 10, 1001, 0.5f, 1.0f,
0.0f, 0.0f, 0.0f}))
.dispatched());
EXPECT_TRUE(router_
.HandleEvent(Event(platform::TouchEvent{
platform::TouchEvent::Action::kDown, 20, 2002, 0.1f, 0.1f,
0.0f, 0.0f, 1.0f}))
.dispatched());
ASSERT_EQ(probe_.touches.size(), 5U);
EXPECT_EQ(probe_.touches[0].pointer_id, 0);
EXPECT_EQ(probe_.touches[0].action, 0);
EXPECT_FLOAT_EQ(probe_.touches[0].x, 320.0f);
EXPECT_FLOAT_EQ(probe_.touches[0].y, 360.0f);
EXPECT_EQ(probe_.touches[0].width, 1280);
EXPECT_EQ(probe_.touches[0].height, 720);
EXPECT_EQ(probe_.touches[1].pointer_id, 1);
EXPECT_EQ(probe_.touches[2].pointer_id, 0);
EXPECT_EQ(probe_.touches[2].action, 1);
EXPECT_EQ(probe_.touches[3].pointer_id, 0);
EXPECT_EQ(probe_.touches[3].action, 2);
EXPECT_EQ(probe_.touches[4].pointer_id, 0);
EXPECT_EQ(probe_.touches[4].action, 0);
}
TEST_F(RobloxInputRouterTest, ResizeChangesTouchScaleAndNativeViewport) {
RobloxInputDispatchResult resize = router_.HandleEvent(
Event(platform::WindowResizedEvent{640, 360, 1920, 1080, 3.0F}));
EXPECT_EQ(resize.state, RobloxInputDispatchState::kStateUpdated);
EXPECT_TRUE(router_
.HandleEvent(Event(
platform::TouchEvent{platform::TouchEvent::Action::kDown,
1, 2, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f}))
.dispatched());
ASSERT_EQ(probe_.touches.size(), 1U);
EXPECT_FLOAT_EQ(probe_.touches[0].x, 320.0f);
EXPECT_FLOAT_EQ(probe_.touches[0].y, 180.0f);
EXPECT_EQ(probe_.touches[0].width, 640);
EXPECT_EQ(probe_.touches[0].height, 360);
EXPECT_EQ(router_.Snapshot().viewport.pixel_width, 1920);
}
TEST_F(RobloxInputRouterTest, RoutesMappedHardwareKeyAndRepeat) {
RobloxInputDispatchResult down = router_.HandleEvent(
Event(platform::KeyEvent{true, false, SDL_SCANCODE_W, SDLK_W, 0}));
RobloxInputDispatchResult repeat = router_.HandleEvent(
Event(platform::KeyEvent{true, true, SDL_SCANCODE_W, SDLK_W, 0}));
RobloxInputDispatchResult up = router_.HandleEvent(
Event(platform::KeyEvent{false, false, SDL_SCANCODE_W, SDLK_W, 0}));
EXPECT_TRUE(down.dispatched());
EXPECT_TRUE(repeat.dispatched());
EXPECT_TRUE(up.dispatched());
ASSERT_EQ(probe_.keys.size(), 3U);
EXPECT_EQ(probe_.keys[0].scan_code, 17);
EXPECT_EQ(probe_.keys[0].key_code, 51);
EXPECT_FALSE(probe_.keys[0].repeat);
EXPECT_TRUE(probe_.keys[1].repeat);
EXPECT_FALSE(probe_.keys[2].pressed);
}
TEST_F(RobloxInputRouterTest, FocusLossReleasesAllPressedInputs) {
ASSERT_TRUE(router_
.HandleEvent(Event(platform::MouseButtonEvent{
true, SDL_BUTTON_LEFT, 1, 12.0f, 34.0f}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(
platform::TouchEvent{platform::TouchEvent::Action::kDown,
7, 8, 0.1f, 0.2f, 0.0f, 0.0f, 1.0f}))
.dispatched());
ASSERT_TRUE(router_
.HandleEvent(Event(platform::KeyEvent{
true, false, SDL_SCANCODE_SPACE, SDLK_SPACE, 0}))
.dispatched());
RobloxInputDispatchResult focus =
router_.HandleEvent(Event(platform::WindowFocusEvent{false}));
EXPECT_EQ(focus.state, RobloxInputDispatchState::kStateUpdated);
ASSERT_EQ(probe_.mouse_buttons.size(), 2U);
EXPECT_FALSE(probe_.mouse_buttons.back().pressed);
ASSERT_EQ(probe_.touches.size(), 2U);
EXPECT_EQ(probe_.touches.back().action, 2);
ASSERT_EQ(probe_.keys.size(), 2U);
EXPECT_FALSE(probe_.keys.back().pressed);
EXPECT_EQ(router_.Snapshot().active_mouse_buttons, 0U);
EXPECT_EQ(router_.Snapshot().active_touches, 0U);
EXPECT_EQ(router_.Snapshot().active_keys, 0U);
const auto motion = router_.HandleEvent(
Event(platform::MouseMotionEvent{1.0f, 2.0f, 1.0f, 1.0f, 0}));
EXPECT_EQ(motion.state, RobloxInputDispatchState::kDispatched);
EXPECT_TRUE(router_.Snapshot().focused);
}
TEST_F(RobloxInputRouterTest, TextFailsClosedWithoutTextboxHandle) {
RobloxInputDispatchResult result =
router_.HandleEvent(Event(platform::TextInputEvent{"hello"}));
EXPECT_EQ(result.state, RobloxInputDispatchState::kIgnoredUnsupported);
EXPECT_EQ(result.kind, RobloxInputEventKind::kText);
EXPECT_EQ(result.status.code(), StatusCode::kUnsupported);
EXPECT_EQ(router_.Snapshot().ignored_text_events, 1U);
EXPECT_EQ(probe_.CallCount(), 0U);
}
TEST_F(RobloxInputRouterTest, FocusedTextSuppressesDuplicateHardwareEditKeys) {
ASSERT_TRUE(router_.BeginTextFocusSession({42, 1, "a", false, false}).ok());
EXPECT_TRUE(
router_.HandleEvent(Event(platform::TextInputEvent{"b"})).dispatched());
EXPECT_TRUE(router_
.HandleEvent(Event(platform::KeyEvent{
true, false, SDL_SCANCODE_BACKSPACE, SDLK_BACKSPACE, 0}))
.dispatched());
const auto key_up = router_.HandleEvent(Event(platform::KeyEvent{
false, false, SDL_SCANCODE_BACKSPACE, SDLK_BACKSPACE, 0}));
EXPECT_EQ(key_up.state, RobloxInputDispatchState::kStateUpdated);
EXPECT_TRUE(probe_.keys.empty());
EXPECT_EQ(probe_.text_operations,
(std::vector<std::string>{"sync", "pass", "sync", "pass"}));
EXPECT_EQ(router_.Snapshot().text_events, 2u);
}
TEST_F(RobloxInputRouterTest, ReportsHostTextFocusCompletion) {
for (const auto scancode : {SDL_SCANCODE_RETURN, SDL_SCANCODE_ESCAPE}) {
ASSERT_TRUE(router_.BeginTextFocusSession({42, 3, "", false, false}).ok());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 3U);
ASSERT_TRUE(router_.HandleEvent(Event(platform::KeyEvent{
true, false, static_cast<uint32_t>(scancode), 0, 0})).status.ok());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 0U);
}
ASSERT_TRUE(router_.BeginTextFocusSession({43, 4, "", true, false}).ok());
ASSERT_TRUE(router_.HandleEvent(Event(platform::KeyEvent{
true, false, SDL_SCANCODE_RETURN, SDLK_RETURN, 0})).status.ok());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 4U);
}
TEST_F(RobloxInputRouterTest, MouseClicksKeepTextFocusUntilEngineReleasesIt) {
RobloxTextFocusSession session{42, 1, "draft", false, false};
session.area_x = 40;
session.area_y = 300;
session.area_width = 400;
session.area_height = 30;
ASSERT_TRUE(router_.BeginTextFocusSession(session).ok());
for (const auto button : {SDL_BUTTON_LEFT, SDL_BUTTON_MIDDLE,
SDL_BUTTON_RIGHT}) {
SCOPED_TRACE(button);
for (const bool pressed : {true, false}) {
ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseButtonEvent{
pressed, static_cast<uint8_t>(button), 1, 100.0F, 310.0F}))
.dispatched());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 1U);
}
}
EXPECT_TRUE(probe_.text_operations.empty());
EXPECT_EQ(probe_.mouse_buttons.size(), 6U);
EXPECT_TRUE(router_.HandleEvent(Event(platform::TextInputEvent{" next"}))
.dispatched());
EXPECT_EQ(probe_.text_operations,
(std::vector<std::string>{"sync", "pass"}));
ASSERT_TRUE(router_.EndTextFocusSession(42, 1, false).ok());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 0U);
EXPECT_FALSE(router_.HandleEvent(Event(platform::TextInputEvent{"late"}))
.dispatched());
}
TEST_F(RobloxInputRouterTest, MouseClickRespectsManualTextFocusRelease) {
ASSERT_TRUE(router_.BeginTextFocusSession({42, 1, "draft", true, false}).ok());
ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseButtonEvent{
true, SDL_BUTTON_LEFT, 1, 900.0F, 600.0F})).dispatched());
EXPECT_TRUE(probe_.text_operations.empty());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 1U);
EXPECT_TRUE(router_.HandleEvent(Event(platform::TextInputEvent{" next"}))
.dispatched());
}
TEST(RobloxInputRouterClipboardTest,
CtrlVPastesWithoutForwardingTheLetterKeyToRoblox) {
Probe probe;
ClipboardProbe clipboard;
clipboard.value = u8"clipboard🙂";
RobloxInputRouter router(Sink(&probe), {}, &clipboard);
ASSERT_TRUE(router.Activate({1280, 720, 2560, 1440}, true).ok());
ASSERT_TRUE(router.BeginTextFocusSession({42, 1, "old", false, false}).ok());
SDL_Event source{};
source.type = SDL_EVENT_KEY_DOWN;
source.key.down = true;
source.key.scancode = SDL_SCANCODE_V;
source.key.key = SDLK_V;
source.key.mod = SDL_KMOD_CTRL;
platform::PlatformEvent converted;
auto* non_owning_window = reinterpret_cast<SDL_Window*>(0x1);
ASSERT_TRUE(
platform::ConvertSdlEvent(non_owning_window, source, &converted));
const RobloxInputDispatchResult down = router.HandleEvent(converted);
source.type = SDL_EVENT_KEY_UP;
source.key.down = false;
source.key.mod = SDL_KMOD_NONE;
ASSERT_TRUE(
platform::ConvertSdlEvent(non_owning_window, source, &converted));
const RobloxInputDispatchResult up = router.HandleEvent(converted);
EXPECT_TRUE(down.dispatched());
EXPECT_EQ(down.kind, RobloxInputEventKind::kText);
EXPECT_EQ(up.state, RobloxInputDispatchState::kStateUpdated);
EXPECT_EQ(clipboard.read_calls, 1);
EXPECT_TRUE(probe.keys.empty());
EXPECT_EQ(probe.text_operations,
(std::vector<std::string>{"sync", "pass"}));
EXPECT_EQ(router.Snapshot().text_events, 1u);
EXPECT_EQ(router.Snapshot().keyboard_events, 0u);
}
TEST_F(RobloxInputRouterTest,
FocusedTextRoutesAsciiKeyPairAndCommittedTextInSdlOrder) {
ASSERT_TRUE(router_.BeginTextFocusSession({42, 1, "", false, false}).ok());
const RobloxInputDispatchResult down = router_.HandleEvent(
Event(platform::KeyEvent{true, false, SDL_SCANCODE_A, SDLK_A, 0}));
const RobloxInputDispatchResult text =
router_.HandleEvent(Event(platform::TextInputEvent{"a"}));
const RobloxInputDispatchResult up = router_.HandleEvent(
Event(platform::KeyEvent{false, false, SDL_SCANCODE_A, SDLK_A, 0}));
EXPECT_TRUE(down.dispatched());
EXPECT_EQ(down.kind, RobloxInputEventKind::kKeyboard);
EXPECT_TRUE(text.dispatched());
EXPECT_EQ(text.kind, RobloxInputEventKind::kText);
EXPECT_TRUE(up.dispatched());
EXPECT_EQ(up.kind, RobloxInputEventKind::kKeyboard);
ASSERT_EQ(probe_.keys.size(), 2u);
EXPECT_TRUE(probe_.keys[0].pressed);
EXPECT_EQ(probe_.keys[0].scan_code, 30);
EXPECT_EQ(probe_.keys[0].key_code, 29);
EXPECT_FALSE(probe_.keys[0].repeat);
EXPECT_FALSE(probe_.keys[1].pressed);
EXPECT_EQ(probe_.keys[1].scan_code, 30);
EXPECT_EQ(probe_.keys[1].key_code, 29);
EXPECT_FALSE(probe_.keys[1].repeat);
EXPECT_EQ(probe_.ordered_operations,
(std::vector<std::string>{"key-down", "sync", "pass", "key-up"}));
const RobloxInputSnapshot snapshot = router_.Snapshot();
EXPECT_EQ(snapshot.keyboard_events, 2u);
EXPECT_EQ(snapshot.text_events, 1u);
EXPECT_EQ(snapshot.ignored_text_events, 0u);
EXPECT_EQ(snapshot.active_keys, 0u);
EXPECT_EQ(snapshot.native_errors, 0u);
}
TEST_F(RobloxInputRouterTest, FocusedTextEditingPreeditStreamsToNative) {
ASSERT_TRUE(router_.BeginTextFocusSession({42, 1, "", false, false}).ok());
const RobloxInputDispatchResult result =
router_.HandleEvent(Event(platform::TextEditingEvent{"preedit", 0, 7}));
EXPECT_EQ(result.state, RobloxInputDispatchState::kDispatched);
EXPECT_EQ(result.kind, RobloxInputEventKind::kText);
EXPECT_TRUE(result.status.ok());
EXPECT_TRUE(probe_.keys.empty());
EXPECT_EQ(probe_.text_operations, (std::vector<std::string>{"sync", "pass"}));
EXPECT_EQ(probe_.ordered_operations,
(std::vector<std::string>{"sync", "pass"}));
const RobloxInputSnapshot snapshot = router_.Snapshot();
EXPECT_EQ(snapshot.keyboard_events, 0u);
EXPECT_EQ(snapshot.text_events, 1u);
EXPECT_EQ(snapshot.ignored_text_events, 0u);
EXPECT_EQ(snapshot.active_keys, 0u);
EXPECT_EQ(snapshot.native_errors, 0u);
}
TEST_F(RobloxInputRouterTest, WindowFocusLossReleasesFocusedTextbox) {
ASSERT_TRUE(
router_.BeginTextFocusSession({42, 1, "secret", false, false}).ok());
const auto result =
router_.HandleEvent(Event(platform::WindowFocusEvent{false}));
EXPECT_EQ(result.state, RobloxInputDispatchState::kStateUpdated);
EXPECT_EQ(probe_.text_operations, (std::vector<std::string>{"handle"}));
const auto text = router_.HandleEvent(Event(platform::TextInputEvent{"x"}));
EXPECT_EQ(text.state, RobloxInputDispatchState::kIgnoredUnsupported);
}
TEST_F(RobloxInputRouterTest, DeactivatePreventsAllLaterNativeCalls) {
ASSERT_TRUE(router_.Deactivate().ok());
const size_t before = probe_.CallCount();
RobloxInputDispatchResult result = router_.HandleEvent(Event(
platform::MouseButtonEvent{true, SDL_BUTTON_LEFT, 1, 100.0f, 100.0f}));
EXPECT_EQ(result.state, RobloxInputDispatchState::kIgnoredInactive);
EXPECT_EQ(probe_.CallCount(), before);
}
TEST_F(RobloxInputRouterTest,
ConvertedSdlClickTraversesTheProductionTypedPath) {
SDL_Event source{};
source.type = SDL_EVENT_MOUSE_BUTTON_DOWN;
source.button.down = true;
source.button.button = SDL_BUTTON_LEFT;
source.button.clicks = 1;
source.button.x = 321.0f;
source.button.y = 123.0f;
platform::PlatformEvent converted;
auto* non_owning_window = reinterpret_cast<SDL_Window*>(0x1);
ASSERT_TRUE(platform::ConvertSdlEvent(non_owning_window, source, &converted));
RobloxInputDispatchResult result = router_.HandleEvent(converted);
ASSERT_TRUE(result.dispatched());
ASSERT_EQ(probe_.mouse_buttons.size(), 1U);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].x, 321.0f);
EXPECT_FLOAT_EQ(probe_.mouse_buttons[0].y, 123.0f);
EXPECT_TRUE(probe_.mouse_buttons[0].pressed);
EXPECT_EQ(probe_.mouse_buttons[0].button, 0);
}
TEST_F(RobloxInputRouterTest, NativeFailuresAreObservableAndCounted) {
probe_.next_status =
Status::Error(StatusCode::kPlatformError, "injected failure");
RobloxInputDispatchResult result = router_.HandleEvent(
Event(platform::MouseMotionEvent{10.0f, 20.0f, 1.0f, 2.0f, 0}));
EXPECT_EQ(result.state, RobloxInputDispatchState::kNativeError);
EXPECT_EQ(result.status.code(), StatusCode::kPlatformError);
EXPECT_EQ(router_.Snapshot().native_errors, 1U);
EXPECT_EQ(router_.Snapshot().mouse_events, 0U);
}
} // namespace
} // namespace runtime
} // namespace mocktail