Files
firmware/test/test_snake/test_main.cpp
T
Jonathan BennettandClaude Opus 5 ee02cc3426 Games joystick input (#11917)
* fix(games): correct the high-score announcement argument order

GAMES_HIGH_SCORE_STRING is "New %s high score %lu by %s!" but the arguments
were passed as (name, initials, score): the initials string was formatted
through %lu and the score integer through %s. That is a format/argument
mismatch, so the announcement printed garbage at best and dereferenced the
score as a pointer at worst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(input): report which physical gamepad button produced an event

A joystick event only carried the action it was mapped to, so a consumer could
not tell two buttons apart once they shared one action, and games were limited
to the handful of actions the broker defines.

Carry the originating evdev button code in InputEvent::kbchar, encoded into a
reserved 0xC0..0xDF range that misses printable ASCII and every
INPUT_BROKER_MSG_ value (SystemCommands switches on kbchar without looking at
inputEvent, so a collision there would reboot the node rather than move a
paddle). D-pad events are axes, not buttons, and keep leaving kbchar at 0 --
which is exactly what lets a consumer tell stick from button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(portduino): let one joystick action bind several buttons

Input.JoystickButtons took a single evdev code per action, so a pad's A and Y
could not both select, and the shoulder buttons could not sit alongside the
D-pad. Accept a list of codes as well as a bare scalar; the config writer
inverts its code->action map back out, emitting a list only where an action
has more than one button.

ConfigCheck gains a real checker for the section (it was previously waved
through as free-form) covering the three ways a mapping silently does nothing:
an action name the driver does not know, an evdev name where the numeric code
belongs, and one code claimed by two actions. Two fixtures and shell-test
cases cover the clean list form and those three faults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(games): use the gamepad's extra buttons, and return home when idle

Games now receive the physical button alongside the action, so a pad with more
than two usable buttons controls more than two things:

- Snake: a shoulder button mapped to left/right turns relative to the snake's
  heading (L counter-clockwise, R clockwise) while the D-pad keeps steering
  absolutely. The two are told apart by kbchar, not by hardcoding one pad's
  codes.
- Breakout: the ball now rides the paddle after each serve until the player
  fires it with B or A, so a life is not lost to a ball already in flight when
  the player looks up. The paddle also keeps its position between lives. A game
  can claim BACK for the duration (Game::wantsBackButton) so B serves instead
  of pausing, and releases it once the ball is live.
- Start (BTN_BASE4 / BTN_START) is mapped to select like any other button, so
  it launches games and drives the menus; inside a running game GamesModule
  picks it out of kbchar and pauses instead.

Separately, the games frame no longer holds a walked-away device hostage: after
15 s with no input it returns to the home frame, so the device still reads as a
Meshtastic node. The timer is suspended while a picker or banner is up (e.g.
high-score initials entry, which the input handler never sees) so it cannot
yank the user out mid-entry.

Screen::isInteractionBusy() generalises the old module-intercept check --
modal module, intercepting module, game, or an open interactive overlay --
and MessageRenderer uses it before popping an incoming-message banner. A
transient banner REPLACES an active overlay, so an arriving message could
otherwise discard a half-entered high score. The message is still stored, its
thread still selected, and the unread indicator still set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(ui): compose freetext on the on-screen keyboard from a gamepad

A gamepad can drive the on-screen keyboard but cannot type, so on a host with
a joystick and no configured keyboard device the OSK is the only way to compose
freetext. Set osk_found there, and gate the "Freetext" menu entries on whether
the device can enter text at all (physical keyboard, OSK, or touchscreen
virtual keyboard) rather than on kb_found alone -- those entries were hidden on
exactly the devices that needed them.

The OSK prompt that CannedMessageModule already had inline in the message
selector becomes showOnScreenKeyboard(), so the menu path can reach it too.
Menus call in from a banner callback and the banner is torn down as soon as
that callback returns, which would take the keyboard down with it, so the menu
path defers the launch to runOnce().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(games): address review on frame fallback and joystick input gating

Breakout: the paddle suppression was far too broad. aLinuxJoystick is
constructed on every Linux host whether or not a gamepad is configured
(InputBroker.cpp), so `aLinuxJoystick && kbchar == 0` was true everywhere and
swallowed LEFT/RIGHT from the keyboard, trackball and ExpressLRS -- on a host
with no joystick attached at all. Gate on the stick actually driving the paddle
instead: LinuxJoystick assigns heldX before it emits and only auto-repeats while
heldX is set, so every axis LEFT/RIGHT arrives with a zone held and nothing else
does. kbchar == 0 still distinguishes an axis from a shoulder button mapped to
left/right, which must keep nudging the paddle.

Screen: showHomeFrame() did nothing when the home frame was hidden, since
setFrames() only assigns positions.home for !hiddenFrames.home. That stranded
the games inactivity bounce on the frame it was trying to leave. Fall back to
the messages frame, which setFrames() always adds.

Test: rename test_ballWaitsOnPaddleUntilLaunched to
test_ball_waitsOnPaddleUntilLaunched, matching the repo convention and its
neighbours in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(games): give games the whole InputEvent so Breakout can identify the source

Follow-up to review on #11917. The previous narrowing still could not tell
sources apart: kbchar == 0 is shared by the joystick's D-pad axis and by every
other driver that sends a bare LEFT/RIGHT, so while the D-pad was held a
keyboard or touchscreen press was still discarded. heldXZone() proves the axis
is driving, not that this particular event came from it.

Pass the event itself to Game::handleInput() rather than (ev, kbchar). Games
that only care about the action read event->inputEvent; Snake keeps using
kbchar for shoulder steering; Breakout now also checks event->source against
LinuxJoystick's origin name, so only that driver's own axis repeats are
suppressed.

Chose the event over a third positional parameter so the signature does not
have to grow again the next time a game needs something the event already
carries.

All three conditions in Breakout are load-bearing: source says it came from
this gamepad, kbchar == 0 says it is the axis rather than a shoulder button
mapped to left/right, and heldXZone() != 0 says the axis is what is driving
right now so tick() already has it covered.

LinuxJoystick::originName() exposes the name the driver stamps into
InputEvent::source, alongside the existing heldXZone()/heldYZone() accessors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 04:11:18 +00:00

223 lines
7.6 KiB
C++

#include "TestUtil.h"
#include "modules/games/Snake.h"
#include <unity.h>
// Pure-logic tests for SnakeGame: ring-buffer advance, reversal rejection, wall/self collision,
// growth on eat, and food-placement validity. No device globals or display stack required.
static const uint32_t kSeed = 0xC0FFEEu;
// Count how many board cells the snake currently occupies (cross-check for len()).
static uint16_t countOccupied(const SnakeGame &game)
{
uint16_t n = 0;
for (uint8_t y = 0; y < SnakeGame::GRID_H; y++)
for (uint8_t x = 0; x < SnakeGame::GRID_W; x++)
if (game.occupied(x, y))
n++;
return n;
}
static void test_reset_initialState()
{
SnakeGame game;
game.reset(kSeed);
TEST_ASSERT_TRUE(game.isPlaying());
TEST_ASSERT_FALSE(game.isWon());
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length());
TEST_ASSERT_EQUAL_UINT32(0u, game.score());
TEST_ASSERT_EQUAL_INT(SnakeGame::DIR_RIGHT, game.direction());
// Head spawns at board centre; the whole test file relies on this anchor.
SnakeGame::Cell head = game.head();
TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_W / 2, head.x);
TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_H / 2, head.y);
// Exactly START_LEN cells occupied, and the head is one of them.
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game));
TEST_ASSERT_TRUE(game.occupied(head.x, head.y));
}
static void test_food_isValidAndOffBody()
{
SnakeGame game;
game.reset(kSeed);
SnakeGame::Cell food = game.food();
TEST_ASSERT_TRUE(food.x < SnakeGame::GRID_W);
TEST_ASSERT_TRUE(food.y < SnakeGame::GRID_H);
TEST_ASSERT_FALSE(game.occupied(food.x, food.y)); // food never spawns on the snake
}
static void test_setDirection_rejectsReversal()
{
SnakeGame game;
game.reset(kSeed); // heading right
TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT)); // 180 reversal -> rejected
TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_UP)); // perpendicular -> ok
TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_RIGHT)); // same as committed dir -> ok (no-op)
// A double-input within one tick can't chain into a reversal: after latching UP, LEFT is
// still checked against the committed RIGHT and rejected, so the neck stays safe.
game.setDirection(SnakeGame::DIR_UP);
TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT));
}
// turn() is the shoulder-button steering: a quarter turn relative to the current heading rather
// than an absolute direction. A quarter turn is never a reversal, so it always takes.
static void test_turn_cyclesThroughHeadings()
{
SnakeGame game;
game.reset(kSeed); // heading right
game.placeFoodAt(0, 0);
// Clockwise on screen (y grows downward): RIGHT -> DOWN -> LEFT -> UP -> RIGHT.
const SnakeGame::Direction cw[] = {SnakeGame::DIR_DOWN, SnakeGame::DIR_LEFT, SnakeGame::DIR_UP, SnakeGame::DIR_RIGHT};
for (SnakeGame::Direction want : cw) {
game.turn(true);
TEST_ASSERT_TRUE(game.step()); // commit the pending turn
TEST_ASSERT_EQUAL_INT(want, game.direction());
}
// Counter-clockwise runs the cycle backwards: RIGHT -> UP -> LEFT -> DOWN -> RIGHT.
const SnakeGame::Direction ccw[] = {SnakeGame::DIR_UP, SnakeGame::DIR_LEFT, SnakeGame::DIR_DOWN, SnakeGame::DIR_RIGHT};
for (SnakeGame::Direction want : ccw) {
game.turn(false);
TEST_ASSERT_TRUE(game.step());
TEST_ASSERT_EQUAL_INT(want, game.direction());
}
}
// Two turns inside one tick must not chain into a 180 that runs the head into its own neck --
// the second turn is taken from the committed heading, not the pending one.
static void test_turn_twiceInOneTickIsNotAReversal()
{
SnakeGame game;
game.reset(kSeed); // heading right
game.placeFoodAt(0, 0);
game.turn(true);
game.turn(true); // would be RIGHT -> DOWN -> LEFT if it chained
TEST_ASSERT_TRUE(game.step());
TEST_ASSERT_EQUAL_INT(SnakeGame::DIR_DOWN, game.direction());
TEST_ASSERT_TRUE(game.isPlaying());
}
static void test_step_movesAndTailFollows()
{
SnakeGame game;
game.reset(kSeed);
SnakeGame::Cell head = game.head();
game.placeFoodAt(0, 0); // corner, off the snake -> guaranteed non-eating step
TEST_ASSERT_TRUE(game.step());
SnakeGame::Cell newHead = game.head();
TEST_ASSERT_EQUAL_UINT8(head.x + 1, newHead.x); // moved one cell right
TEST_ASSERT_EQUAL_UINT8(head.y, newHead.y);
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length()); // length unchanged when not eating
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game));
TEST_ASSERT_EQUAL_UINT32(0u, game.score());
}
static void test_eat_growsAndScores()
{
SnakeGame game;
game.reset(kSeed);
SnakeGame::Cell head = game.head();
game.placeFoodAt(head.x + 1, head.y); // food directly ahead
TEST_ASSERT_TRUE(game.step());
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, game.length()); // grew by one
TEST_ASSERT_EQUAL_UINT32(1u, game.score());
TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, countOccupied(game));
// A fresh food was placed and is not on the snake.
SnakeGame::Cell food = game.food();
TEST_ASSERT_FALSE(game.occupied(food.x, food.y));
}
static void test_wallCollision_endsGame()
{
SnakeGame game;
game.reset(kSeed);
game.placeFoodAt(0, 0);
game.setDirection(SnakeGame::DIR_UP); // head is at mid-height; drive straight up into the wall
bool alive = true;
int guard = 0;
while (alive && guard++ < SnakeGame::GRID_H + 4) {
game.placeFoodAt(0, 0); // keep food out of the way each tick
alive = game.step();
}
TEST_ASSERT_FALSE(alive);
TEST_ASSERT_FALSE(game.isPlaying());
}
static void test_selfCollision_endsGame()
{
SnakeGame game;
game.reset(kSeed);
TEST_ASSERT_EQUAL_UINT8(16, game.head().x); // anchor the deterministic path below
TEST_ASSERT_EQUAL_UINT8(6, game.head().y);
// Grow to length 5 along a straight horizontal line (cells (14..18, 6)).
game.placeFoodAt(17, 6);
TEST_ASSERT_TRUE(game.step());
game.placeFoodAt(18, 6);
TEST_ASSERT_TRUE(game.step());
TEST_ASSERT_EQUAL_UINT16(5, game.length());
// Curl back on itself: DOWN, LEFT, then UP re-enters an occupied body cell.
game.setDirection(SnakeGame::DIR_DOWN);
game.placeFoodAt(0, 0);
TEST_ASSERT_TRUE(game.step());
game.setDirection(SnakeGame::DIR_LEFT);
game.placeFoodAt(0, 0);
TEST_ASSERT_TRUE(game.step());
game.setDirection(SnakeGame::DIR_UP);
game.placeFoodAt(0, 0);
TEST_ASSERT_FALSE(game.step()); // bites its own body
TEST_ASSERT_FALSE(game.isPlaying());
}
static void test_deadGame_stepIsNoOp()
{
SnakeGame game;
game.reset(kSeed);
game.setDirection(SnakeGame::DIR_UP);
for (int i = 0; i < SnakeGame::GRID_H + 4; i++) {
game.placeFoodAt(0, 0);
game.step();
}
TEST_ASSERT_FALSE(game.isPlaying());
uint32_t scoreBefore = game.score();
TEST_ASSERT_FALSE(game.step()); // stays dead, no state change
TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score());
}
void setUp(void) {}
void tearDown(void) {}
extern "C" {
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_reset_initialState);
RUN_TEST(test_food_isValidAndOffBody);
RUN_TEST(test_setDirection_rejectsReversal);
RUN_TEST(test_turn_cyclesThroughHeadings);
RUN_TEST(test_turn_twiceInOneTickIsNotAReversal);
RUN_TEST(test_step_movesAndTailFollows);
RUN_TEST(test_eat_growsAndScores);
RUN_TEST(test_wallCollision_endsGame);
RUN_TEST(test_selfCollision_endsGame);
RUN_TEST(test_deadGame_stepIsNoOp);
exit(UNITY_END());
}
void loop() {}
}