add dora-joy node publishing gamepad axes and buttons
validate / validate (pull_request) Successful in 58m42s
validate / test (pull_request) Successful in 58m47s
validate / markdownlint (pull_request) Successful in 58m40s

This commit is contained in:
2026-09-16 05:10:33 -05:00
parent 9f56aa8f75
commit 3624c63ead
24 changed files with 1478 additions and 0 deletions
View File
+30
View File
@@ -0,0 +1,30 @@
"""Shared fakes. Nothing here needs a gamepad."""
import pytest
from dora_joy.config import Config
from dora_joy.gamepad import Sample
def stick(axes=None, buttons=None, present=True):
"""Build one gamepad reading, padded to a full six axes."""
values = list(axes or [])
values += [0.0] * (6 - len(values))
# A trigger at rest reads -1, and a test that forgot to say so would look
# like both triggers held down.
for index in (2, 5):
if axes is None or index >= len(axes):
values[index] = -1.0
return Sample(axes=values, buttons=list(buttons or []), present=present)
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
"""Run every test against a bare environment, whatever the shell set."""
monkeypatch.delenv("JOYSTICK_INDEX", raising=False)
@pytest.fixture
def config():
"""Return the default configuration."""
return Config.from_env()
+31
View File
@@ -0,0 +1,31 @@
"""Settings, and refusing the ones that cannot work."""
import pytest
from dora_joy.config import Config
def test_the_first_pad_is_the_default():
assert Config.from_env().joystick_index == 0
def test_the_index_comes_from_the_environment(monkeypatch):
monkeypatch.setenv("JOYSTICK_INDEX", "2")
assert Config.from_env().joystick_index == 2
def test_an_index_that_is_not_a_number_names_the_variable(monkeypatch):
monkeypatch.setenv("JOYSTICK_INDEX", "left")
with pytest.raises(ValueError, match="JOYSTICK_INDEX"):
Config.from_env()
def test_a_negative_index_is_refused_at_startup(monkeypatch):
# Not at the first read of the pad, which is the one moment nobody is
# watching the node's stderr.
monkeypatch.setenv("JOYSTICK_INDEX", "-1")
with pytest.raises(ValueError, match="JOYSTICK_INDEX"):
Config.from_env()
+55
View File
@@ -0,0 +1,55 @@
"""The wire shape: what a consumer is promised."""
import pyarrow as pa
from dora_joy.gamepad import ABSENT, Sample
from dora_joy.message import JOY, pack
from tests.conftest import stick
def test_a_reading_survives_the_round_trip():
packed = pack(stick(axes=[0.5, -0.5], buttons=[False, True]))
row = packed[0].as_py()
assert row["axes"][:2] == [0.5, -0.5]
assert row["buttons"] == [False, True]
assert row["present"] is True
def test_an_absent_pad_keeps_the_schema():
# pyarrow infers `list<null>` from an empty list, so a pad being unplugged
# would otherwise change the type of the port halfway through a run.
assert pack(ABSENT).type == JOY
assert pack(stick()).type == JOY
def test_an_absent_pad_says_so_rather_than_reading_as_centred():
row = pack(ABSENT)[0].as_py()
assert row["present"] is False
assert row["axes"] == []
def test_axes_are_float32_and_buttons_are_bool():
packed = pack(stick(axes=[1.0]))
assert packed.type.field("axes").type == pa.list_(pa.float32())
assert packed.type.field("buttons").type == pa.list_(pa.bool_())
def test_a_pad_with_more_axes_than_six_is_published_whole():
# A pad whose d-pad arrives as two extra axes is exactly what the probe is
# for, so nothing may be trimmed on the way out.
row = pack(Sample(axes=[0.0] * 8, buttons=[False] * 12, present=True))[0].as_py()
assert len(row["axes"]) == 8
assert len(row["buttons"]) == 12
def test_nothing_is_shaped_on_the_way_out():
# No deadzone here: a consumer that wants one applies its own, and two
# deadzones on one number is a bug nobody can see.
row = pack(stick(axes=[0.01]))[0].as_py()
assert row["axes"][0] != 0.0
+54
View File
@@ -0,0 +1,54 @@
"""The probe: what it draws, without a pad or a terminal."""
from dora_joy.gamepad import Sample
from dora_joy.probe import bar, render
from tests.conftest import stick
def test_a_centred_stick_puts_the_marker_in_the_middle():
drawn = bar(0.0)
assert drawn[len(drawn) // 2] == "#"
def test_the_bar_leans_the_way_the_stick_is_pushed():
left = bar(-1.0)
right = bar(1.0)
assert left.index("#") < left.index("|")
assert right.index("#") > right.index("|")
def test_a_reading_beyond_full_deflection_stays_on_the_bar():
# SDL has been known to report slightly outside -1..1 on a worn stick.
assert "#" in bar(-4.0)
assert "#" in bar(4.0)
def test_no_gamepad_says_so_and_names_the_index_it_looked_for(config):
config.joystick_index = 2
drawn = render(Sample(present=False), config)
assert "no gamepad found" in drawn
assert "index 2" in drawn
def test_every_axis_the_pad_reports_is_shown(config):
# A pad whose sticks land on unexpected indices is what this is for, so
# nothing may be hidden.
drawn = render(Sample(axes=[0.0] * 8, buttons=[], present=True), config)
assert "axes (8 reported)" in drawn
for index in range(8):
assert f" {index} " in drawn
def test_the_buttons_being_held_are_listed_by_index(config):
drawn = render(stick(buttons=[False, True, False, True]), config)
assert "1, 3" in drawn
def test_a_pad_with_nothing_pressed_says_so(config):
assert "none pressed" in render(stick(), config)