Files
InstanceBrick--nightcap/tests/vr/xr_controller_test.cc
T
2026-09-14 16:38:31 +03:00

299 lines
12 KiB
C++

#include <gtest/gtest.h>
#include <cmath>
#include "mocktail/vr/xr_controller.h"
namespace mocktail::vr {
namespace {
using Button = ControllerButton;
using Axis = ControllerAxis;
TEST(XrControllerState,
PublishEmitsOneEdgePerTransitionDespiteRepeatedGetters) {
ControllerStateBuilder builder;
std::vector<ControllerStateBuilder::Edge> edges;
builder.SetConnected(0, true);
builder.SetButton(0, Button::kFaceSouth, true, /*active=*/true);
const auto first = builder.Publish(1, 0, &edges);
ASSERT_EQ(edges.size(), 1u);
EXPECT_EQ(edges[0].hand, 0);
EXPECT_EQ(edges[0].button, Button::kFaceSouth);
EXPECT_TRUE(edges[0].pressed);
// Repeated publishes at the same level emit no further edges: native getters
// reading the snapshot must never see duplicate transitions.
edges.clear();
builder.SetButton(0, Button::kFaceSouth, true, true);
builder.Publish(2, 0, &edges);
builder.Publish(3, 0, &edges);
EXPECT_TRUE(edges.empty());
edges.clear();
builder.SetButton(0, Button::kFaceSouth, false, true);
builder.Publish(4, 0, &edges);
ASSERT_EQ(edges.size(), 1u);
EXPECT_FALSE(edges[0].pressed);
EXPECT_EQ(first.hands[0].connected, true);
}
TEST(XrControllerState, InactiveActionsNeverPressAndReleaseHeldControls) {
ControllerStateBuilder builder;
std::vector<ControllerStateBuilder::Edge> edges;
builder.SetButton(0, Button::kMenu, false, true);
builder.Publish(1, 0, &edges);
edges.clear();
// An inactive action reporting "true" must not fabricate a press.
builder.SetButton(0, Button::kMenu, true, /*active=*/false);
builder.Publish(2, 0, &edges);
EXPECT_TRUE(edges.empty());
EXPECT_FALSE(builder.snapshot()
.hands[0]
.buttons[static_cast<std::size_t>(Button::kMenu)]);
}
TEST(XrControllerState, InactiveActionReleasesPreviouslyHeldControl) {
ControllerStateBuilder b;
b.SetButton(0, Button::kMenu, true, true);
b.Publish(1, 0, nullptr);
b.SetButton(0, Button::kMenu, true, false);
std::vector<ControllerStateBuilder::Edge> edges;
b.Publish(2, 0, &edges);
ASSERT_EQ(edges.size(), 1u);
EXPECT_FALSE(edges[0].pressed);
}
TEST(XrControllerState, ReleaseAllEmitsReleaseForHeldButtonsOnlyAndZeroesAxes) {
ControllerStateBuilder builder;
std::vector<ControllerStateBuilder::Edge> edges;
builder.SetButton(0, Button::kFaceSouth, true, true);
builder.SetButton(1, Button::kSqueeze, true, true);
builder.SetAxis(0, Axis::kThumbstickX, 0.8f, true);
builder.SetAxis(0, Axis::kTrigger, 1.f, true);
builder.Publish(1, 0, &edges);
EXPECT_EQ(edges.size(), 2u);
edges.clear();
builder.ReleaseAll(2, 0, &edges);
ASSERT_EQ(edges.size(), 2u);
for (const auto &edge : edges) {
EXPECT_FALSE(edge.pressed);
}
const auto &snapshot = builder.snapshot();
EXPECT_FLOAT_EQ(
snapshot.hands[0].axes[static_cast<std::size_t>(Axis::kThumbstickX)],
0.f);
EXPECT_FLOAT_EQ(
snapshot.hands[0].axes[static_cast<std::size_t>(Axis::kTrigger)], 0.f);
// A second ReleaseAll must not re-emit anything (no stuck-down, no repeats).
edges.clear();
builder.ReleaseAll(3, 0, &edges);
EXPECT_TRUE(edges.empty());
}
TEST(XrControllerState, AxesAreClampedAndNonFiniteRejected) {
ControllerStateBuilder builder;
builder.SetAxis(0, Axis::kThumbstickY, 4.5f, true);
builder.SetAxis(1, Axis::kTrigger, -2.f, true);
builder.SetAxis(0, Axis::kTrigger, 0.5f, true);
builder.SetAxis(0, Axis::kTrigger, std::nanf(""), true);
builder.Publish(1, 0, nullptr);
const auto &s = builder.snapshot();
EXPECT_FLOAT_EQ(s.hands[0].axes[static_cast<std::size_t>(Axis::kThumbstickY)],
1.f);
EXPECT_FLOAT_EQ(s.hands[1].axes[static_cast<std::size_t>(Axis::kTrigger)],
0.f);
// Invalid input must neutralize any previously held trigger.
EXPECT_FLOAT_EQ(s.hands[0].axes[static_cast<std::size_t>(Axis::kTrigger)],
0.f);
}
TEST(XrControllerState, AnalogClickHysteresis) {
ControllerStateBuilder builder;
builder.SetAxis(0, Axis::kTrigger, 0.7f, true);
EXPECT_FALSE(builder.AnalogClick(0, Axis::kTrigger)); // below press 0.75
builder.SetAxis(0, Axis::kTrigger, 0.8f, true);
EXPECT_TRUE(builder.AnalogClick(0, Axis::kTrigger)); // latched on
builder.SetAxis(0, Axis::kTrigger, 0.65f, true);
EXPECT_TRUE(builder.AnalogClick(0, Axis::kTrigger)); // inside hysteresis band
builder.SetAxis(0, Axis::kTrigger, 0.55f, true);
EXPECT_FALSE(builder.AnalogClick(0, Axis::kTrigger)); // below release 0.6
// Non-click axes and invalid hands are rejected.
EXPECT_FALSE(builder.AnalogClick(0, Axis::kThumbstickX));
EXPECT_FALSE(builder.AnalogClick(5, Axis::kTrigger));
}
TEST(XrControllerState, SessionGenerationAndFrameTrackPublish) {
ControllerStateBuilder builder;
const auto snapshot = builder.Publish(42, 7, nullptr);
EXPECT_EQ(snapshot.frame, 42u);
EXPECT_EQ(snapshot.session_generation, 7u);
}
TEST(XrControllerAndroidMapping, PerHandKeysAndAxesMatchVerifiedGamepadRoute) {
// Values pinned against MapSdlGamepadButtonToAndroid/kAndroidAxes.
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kFaceSouth), 96);
EXPECT_EQ(ControllerButtonToAndroidKey(1, Button::kFaceSouth), 96);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kFaceEast), 97);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kFaceWest), 99);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kFaceNorth), 100);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kSqueeze), 102); // L1
EXPECT_EQ(ControllerButtonToAndroidKey(1, Button::kSqueeze), 103); // R1
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kThumbstick), 106);
EXPECT_EQ(ControllerButtonToAndroidKey(1, Button::kThumbstick), 107);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kMenu), 108);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kTrigger), -1); // analog
EXPECT_EQ(ControllerAxisToAndroidAxis(0, Axis::kThumbstickX), 0);
EXPECT_EQ(ControllerAxisToAndroidAxis(0, Axis::kThumbstickY), 1);
EXPECT_EQ(ControllerAxisToAndroidAxis(1, Axis::kThumbstickX), 11);
EXPECT_EQ(ControllerAxisToAndroidAxis(1, Axis::kThumbstickY), 14);
EXPECT_EQ(ControllerAxisToAndroidAxis(0, Axis::kTrigger), 17);
EXPECT_EQ(ControllerAxisToAndroidAxis(1, Axis::kTrigger), 18);
EXPECT_EQ(ControllerAxisToAndroidAxis(0, Axis::kSqueeze), -1);
EXPECT_EQ(ControllerAxisComponentCount(Axis::kThumbstickX), 2);
EXPECT_EQ(ControllerAxisComponentCount(Axis::kTrigger), 1);
EXPECT_EQ(ControllerAxisComponentCount(Axis::kSqueeze), 0);
}
TEST(XrControllerState, InvalidHandAndButtonIndicesAreIgnored) {
ControllerStateBuilder builder;
builder.SetButton(-1, Button::kFaceSouth, true, true);
builder.SetButton(9, Button::kFaceSouth, true, true);
builder.SetAxis(7, Axis::kTrigger, 1.f, true);
builder.SetConnected(3, true);
const auto snapshot = builder.Publish(1, 0, nullptr);
EXPECT_FALSE(snapshot.hands[0].connected);
EXPECT_FALSE(snapshot.hands[1].connected);
EXPECT_FALSE(
snapshot.hands[0].buttons[static_cast<std::size_t>(Button::kFaceSouth)]);
}
TEST(XrControllerQueue, BoundedCapacityDropsOldestAndArmsResync) {
ControllerDeliveryQueue queue;
for (std::uint64_t frame = 1; frame <= ControllerDeliveryQueue::kCapacity;
++frame) {
ControllerDelivery delivery;
delivery.frame = frame;
queue.Push(std::move(delivery));
}
EXPECT_EQ(queue.size(), ControllerDeliveryQueue::kCapacity);
EXPECT_FALSE(queue.overflowed());
// One past capacity: the oldest frame is dropped and the newest arrival
// carries the resync request.
ControllerDelivery extra;
extra.frame = 99;
queue.Push(std::move(extra));
EXPECT_EQ(queue.size(), ControllerDeliveryQueue::kCapacity);
EXPECT_EQ(queue.dropped_frames(), 1u);
ControllerDelivery out;
ASSERT_TRUE(queue.Pop(&out));
EXPECT_EQ(out.frame, 2u); // frame 1 was dropped, not frame 2
// Drain to the newest: it must demand a resync because edges were lost.
while (queue.Pop(&out)) {
}
EXPECT_TRUE(out.resync);
EXPECT_EQ(out.frame, 99u);
}
TEST(XrControllerQueue, DeferredOverflowResyncSurvivesDroppedCarrier) {
ControllerDeliveryQueue queue;
for (std::uint64_t frame = 0; frame < ControllerDeliveryQueue::kCapacity;
++frame) {
queue.Push(ControllerDelivery{});
}
// First overflow: this frame carries resync but is itself dropped next.
ControllerDelivery carrier;
carrier.frame = 100;
queue.Push(std::move(carrier));
ControllerDelivery newest;
newest.frame = 101;
queue.Push(std::move(newest));
ControllerDelivery out;
while (queue.Pop(&out)) {
}
// The resync must survive on the frame the consumer actually receives.
EXPECT_TRUE(out.resync);
EXPECT_EQ(out.frame, 101u);
}
TEST(XrControllerQueue, PopOnEmptyIsFalseAndClearEmpties) {
ControllerDeliveryQueue queue;
ControllerDelivery out;
EXPECT_FALSE(queue.Pop(&out));
EXPECT_FALSE(queue.Pop(nullptr));
queue.Push(ControllerDelivery{});
EXPECT_EQ(queue.size(), 1u);
queue.Clear();
EXPECT_EQ(queue.size(), 0u);
}
TEST(XrControllerSdlMapping, OrdinalsMatchRouterExpectations) {
// SDL3 ordinals the existing router converts through
// MapSdlGamepadButtonToAndroid/kAndroidAxes; verified pairs only.
EXPECT_EQ(ControllerButtonToSdlOrdinal(0, Button::kFaceSouth), 0);
EXPECT_EQ(ControllerButtonToSdlOrdinal(0, Button::kMenu), 6);
EXPECT_EQ(ControllerButtonToSdlOrdinal(0, Button::kSqueeze), 9);
EXPECT_EQ(ControllerButtonToSdlOrdinal(1, Button::kSqueeze), 10);
EXPECT_EQ(ControllerButtonToSdlOrdinal(0, Button::kThumbstick), 7);
EXPECT_EQ(ControllerButtonToSdlOrdinal(1, Button::kThumbstick), 8);
EXPECT_EQ(ControllerButtonToSdlOrdinal(0, Button::kTrigger), -1);
EXPECT_EQ(ControllerAxisToSdlOrdinal(0, Axis::kThumbstickX), 0);
EXPECT_EQ(ControllerAxisToSdlOrdinal(0, Axis::kThumbstickY), 1);
EXPECT_EQ(ControllerAxisToSdlOrdinal(1, Axis::kThumbstickX), 2);
EXPECT_EQ(ControllerAxisToSdlOrdinal(1, Axis::kThumbstickY), 3);
EXPECT_EQ(ControllerAxisToSdlOrdinal(0, Axis::kTrigger), 4);
EXPECT_EQ(ControllerAxisToSdlOrdinal(1, Axis::kTrigger), 5);
EXPECT_EQ(ControllerAxisToSdlOrdinal(0, Axis::kSqueeze), -1);
}
} // namespace
} // namespace mocktail::vr
TEST(XrControllerNative,
ExactChannelsMergeBothHandsAndNeverTurnTouchIntoClick) {
mocktail::vr::ControllerSnapshot snapshot;
snapshot.hands[0].connected = snapshot.hands[1].connected = true;
snapshot.hands[0]
.buttons[static_cast<size_t>(mocktail::vr::ControllerButton::kFaceWest)] =
true;
snapshot.hands[1]
.buttons[static_cast<size_t>(mocktail::vr::ControllerButton::kFaceEast)] =
true;
snapshot.hands[0]
.buttons[static_cast<size_t>(mocktail::vr::ControllerButton::kMenu)] =
true;
snapshot.hands[1]
.axes[static_cast<size_t>(mocktail::vr::ControllerAxis::kTrigger)] = .8f;
snapshot.hands[0]
.axes[static_cast<size_t>(mocktail::vr::ControllerAxis::kThumbstickY)] =
1;
snapshot.hands[0]
.touches[static_cast<size_t>(mocktail::vr::ControllerTouch::kA)] = true;
snapshot.hands[0]
.axes[static_cast<size_t>(mocktail::vr::ControllerAxis::kSqueeze)] = .37f;
snapshot.hands[0]
.buttons[static_cast<size_t>(mocktail::vr::ControllerButton::kSqueeze)] =
true;
float channels[28]{};
EXPECT_TRUE(mocktail::vr::EncodeNativeControllerChannels(snapshot, channels));
EXPECT_FLOAT_EQ(channels[4],
.37f); // preserve analog grip, not its click latch
EXPECT_FLOAT_EQ(channels[24], 1); // X
EXPECT_FLOAT_EQ(channels[23], 1); // B
EXPECT_FLOAT_EQ(channels[27], 1); // Start/Menu
EXPECT_FLOAT_EQ(channels[22], 0); // capacitive A is not a press
EXPECT_FLOAT_EQ(channels[12], .8f); // right trigger
EXPECT_FLOAT_EQ(channels[2], 1); // OpenXR up remains positive in native route
snapshot.hands[0].connected = false;
EXPECT_TRUE(mocktail::vr::EncodeNativeControllerChannels(snapshot, channels));
EXPECT_FLOAT_EQ(channels[24], 0);
EXPECT_FLOAT_EQ(channels[27], 0);
EXPECT_FLOAT_EQ(channels[23], 1);
}