Skip to content

Commit 3b496d0

Browse files
Refactor SDL gamepad tests; add DualSense cases
Add CI/coverage/stars badges and clarify README SDL/libinput entries. Refactor Linux SDL test code: add headers, SDL_GameController RAII alias, SdlGamepadConsumerCase, and reusable helpers for inspecting joystick/controller state, waiting for input, and configuring HIDAPI hints. Introduce create/expect helper functions and two new test runners for UHID joysticks and DualSense controllers, then replace the original test with calls to these runners. Add explicit DualSense USB and Bluetooth test cases and no-op stubs for non-Linux builds. These changes reduce duplication and improve coverage for generic gamepad and DualSense behavior in SDL.
1 parent 0c068c5 commit 3b496d0

3 files changed

Lines changed: 277 additions & 39 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# libvirtualhid
22

3+
[![GitHub Workflow Status (CI)](https://img.shields.io/github/actions/workflow/status/lizardbyte/libvirtualhid/ci.yml.svg?branch=master&label=CI%20build&logo=github&style=for-the-badge)](https://github.com/LizardByte/libvirtualhid/actions/workflows/ci.yml?query=branch%3Amaster)
4+
[![Codecov](https://img.shields.io/codecov/c/gh/LizardByte/libvirtualhid?token=2MeMpktxpv&style=for-the-badge&logo=codecov&label=codecov)](https://codecov.io/gh/LizardByte/libvirtualhid)
5+
[![GitHub stars](https://img.shields.io/github/stars/lizardbyte/libvirtualhid.svg?logo=github&style=for-the-badge)](https://github.com/LizardByte/libvirtualhid)
6+
37
`libvirtualhid` is a planned cross-platform C++ library for creating virtual HID
48
input devices for remote streaming hosts and similar low-latency input
59
applications.
@@ -345,7 +349,8 @@ third-party/googletest/ GoogleTest submodule
345349
- [x] Support output report callbacks for rumble and profile-specific feedback.
346350
- [x] Add X11/XTest fallback support for keyboard and mouse only.
347351
- [x] Add examples and integration tests that validate virtual device visibility
348-
through SDL2 for gamepads and libinput for keyboard/mouse.
352+
through SDL2 for generic gamepad input, DualSense USB input, and DualSense
353+
Bluetooth controller discovery, plus libinput for keyboard/mouse.
349354
- [x] Document required Linux permissions and sample udev rules.
350355

351356
### Phase 2B: Linux inputtino Parity
@@ -371,7 +376,8 @@ third-party/googletest/ GoogleTest submodule
371376
- [x] Keep gamepad feedback on UHID output reports. There is no uinput-backed
372377
gamepad path in this library; if one is added later, it must implement Linux
373378
force-feedback upload, erase, playback, and gain handling.
374-
- [x] Expand Linux consumer tests so SDL2 validates controller-specific behavior
379+
- [x] Expand Linux consumer tests so SDL2 validates generic joystick input,
380+
DualSense USB controller input, and DualSense Bluetooth controller discovery,
375381
and libinput validates keyboard, mouse, touchscreen, trackpad, and pen tablet
376382
events.
377383

tests/fixtures/linux_backend_test_hooks.cpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,26 @@
55

66
// standard includes
77
#include <algorithm>
8+
#include <array>
89
#include <atomic>
910
#include <cerrno>
1011
#include <chrono>
12+
#include <cmath>
1113
#include <cstddef>
14+
#include <cstdint>
1215
#include <cstring>
16+
#include <filesystem>
17+
#include <fstream>
18+
#include <iomanip>
19+
#include <map>
20+
#include <memory>
21+
#include <mutex>
22+
#include <numbers>
23+
#include <optional>
24+
#include <set>
25+
#include <sstream>
1326
#include <string>
27+
#include <system_error>
1428
#include <thread>
1529
#include <utility>
1630
#include <vector>

tests/unit/test_linux_consumers.cpp

Lines changed: 255 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,21 @@ namespace {
5151
#if defined(__linux__)
5252
using LibinputContext = std::unique_ptr<libinput, void (*)(libinput *)>;
5353
using LibinputEvent = std::unique_ptr<libinput_event, void (*)(libinput_event *)>;
54+
using SdlGameController = std::unique_ptr<SDL_GameController, void (*)(SDL_GameController *)>;
5455
using SdlJoystick = std::unique_ptr<SDL_Joystick, void (*)(SDL_Joystick *)>;
5556

57+
/**
58+
* @brief SDL-visible gamepad case.
59+
*/
60+
struct SdlGamepadConsumerCase {
61+
lvh::DeviceProfile profile;
62+
std::string_view name_suffix;
63+
std::string_view stable_id;
64+
int minimum_buttons = 1;
65+
int minimum_axes = 2;
66+
bool expect_live_input = true;
67+
};
68+
5669
/**
5770
* @brief Execute cleanup code when a scope exits.
5871
*/
@@ -204,22 +217,97 @@ namespace {
204217
return stream.str();
205218
}
206219

220+
bool sdl_joystick_has_pressed_button(SDL_Joystick *joystick) {
221+
for (int button = 0; button < SDL_JoystickNumButtons(joystick); ++button) {
222+
if (SDL_JoystickGetButton(joystick, button) != 0) {
223+
return true;
224+
}
225+
}
226+
227+
return false;
228+
}
229+
230+
bool sdl_joystick_has_moved_axis(SDL_Joystick *joystick) {
231+
for (int axis = 0; axis < SDL_JoystickNumAxes(joystick); ++axis) {
232+
if (std::abs(static_cast<int>(SDL_JoystickGetAxis(joystick, axis))) > 8000) {
233+
return true;
234+
}
235+
}
236+
237+
return false;
238+
}
239+
207240
bool wait_for_sdl_gamepad_input(SDL_Joystick *joystick) {
208241
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3};
209242

210243
while (std::chrono::steady_clock::now() < deadline) {
211244
pump_sdl_events();
212245

213-
const auto button_pressed = SDL_JoystickNumButtons(joystick) > 0 && SDL_JoystickGetButton(joystick, 0) != 0;
214-
bool axis_moved = false;
215-
for (int axis = 0; axis < SDL_JoystickNumAxes(joystick); ++axis) {
216-
if (std::abs(static_cast<int>(SDL_JoystickGetAxis(joystick, axis))) > 8000) {
217-
axis_moved = true;
218-
break;
219-
}
246+
if (sdl_joystick_has_pressed_button(joystick) && sdl_joystick_has_moved_axis(joystick)) {
247+
return true;
220248
}
221249

222-
if (button_pressed && axis_moved) {
250+
std::this_thread::sleep_for(std::chrono::milliseconds {50});
251+
}
252+
253+
return false;
254+
}
255+
256+
bool sdl_controller_has_pressed_button(SDL_GameController *controller) {
257+
for (int button = SDL_CONTROLLER_BUTTON_A; button < SDL_CONTROLLER_BUTTON_MAX; ++button) {
258+
if (SDL_GameControllerGetButton(controller, static_cast<SDL_GameControllerButton>(button)) != 0) {
259+
return true;
260+
}
261+
}
262+
263+
return false;
264+
}
265+
266+
bool sdl_controller_has_moved_axis(SDL_GameController *controller) {
267+
for (int axis = SDL_CONTROLLER_AXIS_LEFTX; axis < SDL_CONTROLLER_AXIS_MAX; ++axis) {
268+
if (std::abs(static_cast<int>(SDL_GameControllerGetAxis(controller, static_cast<SDL_GameControllerAxis>(axis)))) > 8000) {
269+
return true;
270+
}
271+
}
272+
273+
return false;
274+
}
275+
276+
std::string describe_sdl_controller_state(SDL_GameController *controller) {
277+
std::ostringstream stream;
278+
stream << "controller_type=" << SDL_GameControllerGetType(controller);
279+
280+
for (int button = SDL_CONTROLLER_BUTTON_A; button < SDL_CONTROLLER_BUTTON_MAX; ++button) {
281+
stream << " controller_button[" << button << "]="
282+
<< static_cast<int>(SDL_GameControllerGetButton(controller, static_cast<SDL_GameControllerButton>(button)));
283+
}
284+
285+
for (int axis = SDL_CONTROLLER_AXIS_LEFTX; axis < SDL_CONTROLLER_AXIS_MAX; ++axis) {
286+
stream << " controller_axis[" << axis << "]="
287+
<< SDL_GameControllerGetAxis(controller, static_cast<SDL_GameControllerAxis>(axis));
288+
}
289+
290+
if (auto *joystick = SDL_GameControllerGetJoystick(controller)) {
291+
stream << " " << describe_sdl_state(joystick);
292+
}
293+
294+
return stream.str();
295+
}
296+
297+
bool wait_for_sdl_controller_input(SDL_GameController *controller) {
298+
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3};
299+
auto *joystick = SDL_GameControllerGetJoystick(controller);
300+
301+
while (std::chrono::steady_clock::now() < deadline) {
302+
SDL_GameControllerUpdate();
303+
pump_sdl_events();
304+
305+
const auto controller_button_pressed = sdl_controller_has_pressed_button(controller);
306+
const auto controller_axis_moved = sdl_controller_has_moved_axis(controller);
307+
const auto joystick_button_pressed = joystick != nullptr && sdl_joystick_has_pressed_button(joystick);
308+
const auto joystick_axis_moved = joystick != nullptr && sdl_joystick_has_moved_axis(joystick);
309+
310+
if ((controller_button_pressed || joystick_button_pressed) && (controller_axis_moved || joystick_axis_moved)) {
223311
return true;
224312
}
225313

@@ -229,6 +317,135 @@ namespace {
229317
return false;
230318
}
231319

320+
void configure_sdl_hidapi_hints() {
321+
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
322+
SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1");
323+
SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5", "1");
324+
}
325+
326+
lvh::GamepadCreationResult create_sdl_gamepad(lvh::Runtime &runtime, SdlGamepadConsumerCase test_case) {
327+
lvh::CreateGamepadOptions options;
328+
options.profile = std::move(test_case.profile);
329+
options.profile.name = unique_device_name(test_case.name_suffix);
330+
options.metadata.stable_id = std::string {test_case.stable_id};
331+
332+
return runtime.create_gamepad(options);
333+
}
334+
335+
void expect_sdl_joystick_profile(SDL_Joystick *joystick, const lvh::DeviceProfile &profile, int minimum_buttons, int minimum_axes) {
336+
EXPECT_EQ(SDL_JoystickGetVendor(joystick), profile.vendor_id);
337+
EXPECT_EQ(SDL_JoystickGetProduct(joystick), profile.product_id);
338+
EXPECT_GE(SDL_JoystickNumButtons(joystick), minimum_buttons);
339+
EXPECT_GE(SDL_JoystickNumAxes(joystick), minimum_axes);
340+
}
341+
342+
void expect_sdl_dualsense_controller_profile(SDL_GameController *controller) {
343+
auto *mapping = SDL_GameControllerMapping(controller);
344+
EXPECT_NE(mapping, nullptr) << SDL_GetError();
345+
if (mapping != nullptr) {
346+
SDL_free(mapping);
347+
}
348+
}
349+
350+
void run_sdl_uhid_joystick_test(SdlGamepadConsumerCase test_case) {
351+
configure_sdl_hidapi_hints();
352+
ASSERT_EQ(SDL_Init(SDL_INIT_JOYSTICK | SDL_INIT_EVENTS), 0) << SDL_GetError();
353+
ScopeExit sdl_quit {[]() {
354+
SDL_Quit();
355+
}};
356+
357+
lvh::RuntimeOptions runtime_options;
358+
runtime_options.backend = lvh::BackendKind::platform_default;
359+
auto runtime = lvh::Runtime::create(runtime_options);
360+
ASSERT_TRUE(runtime->capabilities().supports_gamepad);
361+
362+
const auto expected_profile = [&test_case]() {
363+
auto profile = test_case.profile;
364+
profile.name = unique_device_name(test_case.name_suffix);
365+
return profile;
366+
}();
367+
368+
auto created = create_sdl_gamepad(*runtime, test_case);
369+
ASSERT_TRUE(created) << created.status.message();
370+
371+
const auto joystick_index = wait_for_sdl_joystick(expected_profile);
372+
ASSERT_GE(joystick_index, 0);
373+
374+
SdlJoystick joystick {SDL_JoystickOpen(joystick_index), SDL_JoystickClose};
375+
ASSERT_NE(joystick.get(), nullptr) << SDL_GetError();
376+
expect_sdl_joystick_profile(
377+
joystick.get(),
378+
expected_profile,
379+
test_case.minimum_buttons,
380+
test_case.minimum_axes
381+
);
382+
383+
lvh::GamepadState state;
384+
state.buttons.set(lvh::GamepadButton::a);
385+
state.left_stick = {0.75F, -0.5F};
386+
ASSERT_TRUE(created.gamepad->submit(state).ok());
387+
388+
EXPECT_TRUE(wait_for_sdl_gamepad_input(joystick.get())) << describe_sdl_state(joystick.get());
389+
}
390+
391+
void run_sdl_dualsense_controller_test(SdlGamepadConsumerCase test_case) {
392+
configure_sdl_hidapi_hints();
393+
ASSERT_EQ(SDL_Init(SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS), 0) << SDL_GetError();
394+
ScopeExit sdl_quit {[]() {
395+
SDL_Quit();
396+
}};
397+
398+
lvh::RuntimeOptions runtime_options;
399+
runtime_options.backend = lvh::BackendKind::platform_default;
400+
auto runtime = lvh::Runtime::create(runtime_options);
401+
ASSERT_TRUE(runtime->capabilities().supports_gamepad);
402+
403+
const auto expected_profile = [&test_case]() {
404+
auto profile = test_case.profile;
405+
profile.name = unique_device_name(test_case.name_suffix);
406+
return profile;
407+
}();
408+
409+
auto created = create_sdl_gamepad(*runtime, test_case);
410+
ASSERT_TRUE(created) << created.status.message();
411+
412+
const auto joystick_index = wait_for_sdl_joystick(expected_profile);
413+
ASSERT_GE(joystick_index, 0);
414+
ASSERT_EQ(SDL_IsGameController(joystick_index), SDL_TRUE) << SDL_GetError();
415+
416+
SdlGameController controller {SDL_GameControllerOpen(joystick_index), SDL_GameControllerClose};
417+
ASSERT_NE(controller.get(), nullptr) << SDL_GetError();
418+
419+
auto *joystick = SDL_GameControllerGetJoystick(controller.get());
420+
ASSERT_NE(joystick, nullptr) << SDL_GetError();
421+
expect_sdl_joystick_profile(
422+
joystick,
423+
expected_profile,
424+
test_case.minimum_buttons,
425+
test_case.minimum_axes
426+
);
427+
428+
lvh::GamepadState state;
429+
state.buttons.set(lvh::GamepadButton::a);
430+
state.buttons.set(lvh::GamepadButton::b);
431+
state.buttons.set(lvh::GamepadButton::x);
432+
state.buttons.set(lvh::GamepadButton::y);
433+
state.left_stick = {0.75F, -0.5F};
434+
state.right_stick = {-0.25F, 0.5F};
435+
state.left_trigger = 0.25F;
436+
state.right_trigger = 0.75F;
437+
state.acceleration = lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = 3.0F};
438+
state.gyroscope = lvh::Vector3 {.x = 4.0F, .y = 5.0F, .z = 6.0F};
439+
state.battery = lvh::GamepadBattery {.state = lvh::GamepadBatteryState::charging, .percentage = 80};
440+
state.touchpad_contacts[0] = {.id = 1, .active = true, .x = 0.5F, .y = 0.25F};
441+
ASSERT_TRUE(created.gamepad->submit(state).ok());
442+
443+
expect_sdl_dualsense_controller_profile(controller.get());
444+
if (test_case.expect_live_input) {
445+
EXPECT_TRUE(wait_for_sdl_controller_input(controller.get())) << describe_sdl_controller_state(controller.get());
446+
}
447+
}
448+
232449
void destroy_libinput_event(libinput_event *event) {
233450
if (event != nullptr) {
234451
libinput_event_destroy(event);
@@ -306,39 +523,36 @@ namespace {
306523
TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) {
307524
ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid"));
308525

309-
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
310-
ASSERT_EQ(SDL_Init(SDL_INIT_JOYSTICK | SDL_INIT_EVENTS), 0) << SDL_GetError();
311-
ScopeExit sdl_quit {[]() {
312-
SDL_Quit();
313-
}};
314-
315-
lvh::RuntimeOptions runtime_options;
316-
runtime_options.backend = lvh::BackendKind::platform_default;
317-
auto runtime = lvh::Runtime::create(runtime_options);
318-
ASSERT_TRUE(runtime->capabilities().supports_gamepad);
319-
320-
lvh::CreateGamepadOptions options;
321-
options.profile = lvh::profiles::generic_gamepad();
322-
options.profile.name = unique_device_name("SDL Gamepad");
323-
options.metadata.stable_id = "libvirtualhid-sdl-gamepad-test";
324-
325-
auto created = runtime->create_gamepad(options);
326-
ASSERT_TRUE(created) << created.status.message();
526+
run_sdl_uhid_joystick_test({
527+
.profile = lvh::profiles::generic_gamepad(),
528+
.name_suffix = "SDL Gamepad",
529+
.stable_id = "libvirtualhid-sdl-gamepad-test",
530+
});
531+
}
327532

328-
const auto joystick_index = wait_for_sdl_joystick(options.profile);
329-
ASSERT_GE(joystick_index, 0);
533+
TEST_F(LinuxConsumerTest, SdlSeesDualSenseUsbControllerBehavior) {
534+
ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid"));
330535

331-
SdlJoystick joystick {SDL_JoystickOpen(joystick_index), SDL_JoystickClose};
332-
ASSERT_NE(joystick.get(), nullptr) << SDL_GetError();
333-
EXPECT_GE(SDL_JoystickNumButtons(joystick.get()), 1);
334-
EXPECT_GE(SDL_JoystickNumAxes(joystick.get()), 2);
536+
run_sdl_dualsense_controller_test({
537+
.profile = lvh::profiles::dualsense_usb(),
538+
.name_suffix = "SDL DualSense USB",
539+
.stable_id = "02:00:00:00:00:01",
540+
.minimum_buttons = 10,
541+
.minimum_axes = 4,
542+
});
543+
}
335544

336-
lvh::GamepadState state;
337-
state.buttons.set(lvh::GamepadButton::a);
338-
state.left_stick = {0.75F, -0.5F};
339-
ASSERT_TRUE(created.gamepad->submit(state).ok());
545+
TEST_F(LinuxConsumerTest, SdlSeesDualSenseBluetoothControllerDiscovery) {
546+
ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid"));
340547

341-
EXPECT_TRUE(wait_for_sdl_gamepad_input(joystick.get())) << describe_sdl_state(joystick.get());
548+
run_sdl_dualsense_controller_test({
549+
.profile = lvh::profiles::dualsense_bluetooth(),
550+
.name_suffix = "SDL DualSense Bluetooth",
551+
.stable_id = "02:00:00:00:00:02",
552+
.minimum_buttons = 10,
553+
.minimum_axes = 4,
554+
.expect_live_input = false,
555+
});
342556
}
343557

344558
TEST_F(LinuxConsumerTest, LibinputSeesUinputKeyboardKeys) {
@@ -569,6 +783,10 @@ TEST_F(LinuxConsumerTest, LibinputSeesUinputPenTabletTool) {
569783
#else
570784
TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) {}
571785

786+
TEST_F(LinuxConsumerTest, SdlSeesDualSenseUsbControllerBehavior) {}
787+
788+
TEST_F(LinuxConsumerTest, SdlSeesDualSenseBluetoothControllerDiscovery) {}
789+
572790
TEST_F(LinuxConsumerTest, LibinputSeesUinputKeyboardKeys) {}
573791

574792
TEST_F(LinuxConsumerTest, LibinputSeesUinputMouseMotionAndButtons) {}

0 commit comments

Comments
 (0)