Files
keyrelay/tests/test_output.cpp
T
ducoterra ece0313d02 feat: relative mouse on the HID interface, and the verifier to prove it
The board now enumerates as a boot keyboard plus a relative mouse on the same
HID interface. The pointer is captured, never grabbed: Pointer Lock in the
page, so Esc (the browser's own, never forwarded) always frees it, and every
frame restates the whole button byte so a frame lost in the WiFi cannot leave
a button held. Losing the lock, the tab or the link runs the same release
funnel as a stuck key, and boot protocol or `mouse off` refuses mouse frames
with E11 rather than sending bytes a boot interface would misparse as keys.

src/mouse.h owns the report, src/output.h the queues and the funnel; the page,
relayctl.py and watch_target.py carry the button bits and the evdev decoding.
The verifier's own tables are pinned now — REL_WHEEL is 8, not 2, and the
first version guessed from the report's field order, so a working wheel looked
dead while the summary said no scroll happened.

LLM-Generated: true
2026-11-08 23:27:42 -05:00

283 lines
10 KiB
C++

// tests for the emit side of the bridge: tests/test_output.cpp
//
// This suite exists mainly for one property, and it is the property the project got
// wrong once: releasing everything has to TELL the target. Clearing the tracker and the
// queue is not a release, because the last report the target received still says keys are
// down -- and disarm is exactly the moment the controller stops talking, so no later
// frame is coming to carry it. With a mouse added, the same shape appears a second time:
// a button held at disarm time is a drag nobody is holding.
#include <cstring>
#include "config.h"
#include "keystate.h"
#include "mouse.h"
#include "output.h"
#include "test.h"
using kr::KeyboardReport;
using kr::mouse::Frame;
using kr::mouse::kBtnLeft;
using kr::mouse::kBtnRight;
using kr::mouse::MouseReport;
using kr::OutputStage;
namespace {
bool keysClear(const KeyboardReport &r) {
return r.modifiers == 0 && r.reserved == 0 && r.keys[0] == 0 && r.keys[1] == 0 &&
r.keys[2] == 0 && r.keys[3] == 0 && r.keys[4] == 0 && r.keys[5] == 0;
}
bool mouseClear(const MouseReport &r) {
return r.buttons == 0 && r.dx == 0 && r.dy == 0 && r.wheel == 0 && r.pan == 0;
}
// Drain a queue into a flat list so "what would the target have received" is a fact a
// test can count, not an inference about the firmware's loop.
size_t drainKeyboard(OutputStage &s, KeyboardReport *out, size_t cap) {
size_t n = 0;
while (!s.keyQ.empty() && n < cap) {
out[n++] = s.keyQ.front();
s.keyQ.pop();
}
return n;
}
size_t drainMouse(OutputStage &s, MouseReport *out, size_t cap) {
size_t n = 0;
while (!s.mouseQ.empty() && n < cap) {
out[n++] = s.mouseQ.front();
s.mouseQ.pop();
}
return n;
}
} // namespace
int main() {
CASE("a queue keeps distinct transitions and collapses identical ones");
{
OutputStage s;
s.keys.apply(0, nullptr, 0);
CHECK(s.pushKeys() == OutputStage::Pushed::Nothing); // nothing changed yet
const uint8_t a = 0x04;
s.keys.apply(0, &a, 1);
CHECK(s.pushKeys() == OutputStage::Pushed::Ok);
CHECK(s.pushKeys() == OutputStage::Pushed::Nothing); // restating is a no-op
// A fast press and release is two reports. Collapsing those is how a tap vanishes,
// so only *identical* neighbours may collapse.
s.keys.apply(0, nullptr, 0);
CHECK(s.pushKeys() == OutputStage::Pushed::Ok);
KeyboardReport got[8];
CHECK_EQ(drainKeyboard(s, got, 8), 2);
CHECK_EQ(got[0].keys[0], 0x04);
CHECK(keysClear(got[1]));
}
CASE("release with keys held queues the all-up report <-- the bug this guards");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(kr::kModLshift, &k, 1);
CHECK(s.pushKeys() == OutputStage::Pushed::Ok);
CHECK(s.releaseAll(true, true));
KeyboardReport got[8];
const size_t n = drainKeyboard(s, got, 8);
CHECK_EQ(n, 1); // exactly one report: the release
CHECK(keysClear(got[0])); // and it says nothing is pressed
CHECK_EQ(s.keys.held(), 0);
}
CASE("release drops presses that had not gone out yet");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(0, &k, 1);
s.pushKeys(); // queued, never sent
s.releaseAll(true, true);
KeyboardReport got[8];
CHECK_EQ(drainKeyboard(s, got, 8), 1);
CHECK(keysClear(got[0])); // the press is gone; only the release is left
}
CASE("release with buttons held queues the all-up mouse report");
{
OutputStage s;
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Ok);
CHECK(s.releaseAll(true, true));
MouseReport got[8];
CHECK_EQ(drainMouse(s, got, 8), 1);
CHECK(mouseClear(got[0]));
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("a mouse the target cannot receive is never queued, even by a fail-safe");
{
OutputStage s;
// The boot-protocol case. A 5-byte report reaching a target that is reading an
// 8-byte boot keyboard report is not "the mouse is unavailable", it is keystrokes
// nobody pressed -- so the release must not sneak one out on the way past.
s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0});
CHECK_EQ(s.mouseQ.count(), 1u);
s.releaseAll(true, false);
CHECK_EQ(s.mouseQ.count(), 0u);
KeyboardReport got[8];
CHECK_EQ(drainKeyboard(s, got, 8), 1); // the keyboard is unaffected ...
CHECK(keysClear(got[0]));
// ... and the button byte is cleared regardless, because what the board believes must
// not outlive the fail-safe just because the target could not be told.
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("release covers both devices in one call");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(0, &k, 1);
s.pushKeys();
s.pushMouse(Frame{kBtnLeft | kBtnRight, 3, 4, 0, 0});
CHECK_EQ(s.keyQ.count(), 1u);
CHECK_EQ(s.mouseQ.count(), 1u);
CHECK(s.releaseAll(true, true));
KeyboardReport kr8[8];
MouseReport mr8[8];
CHECK_EQ(drainKeyboard(s, kr8, 8), 1);
CHECK_EQ(drainMouse(s, mr8, 8), 1);
CHECK(keysClear(kr8[0]));
CHECK(mouseClear(mr8[0]));
}
CASE("release with nothing held still says so, and that is the trade");
{
OutputStage s;
// The reports are unconditional on purpose: a panic path must not hinge on the
// tracker's bookkeeping being right, and "no buttons, no movement" is what a real
// mouse sends every time it presses a button without moving. Two extra reports on a
// 1 ms bus, and no branch in the fail-safe.
CHECK(s.releaseAll(true, true));
KeyboardReport kr8[8];
MouseReport mr8[8];
CHECK_EQ(drainKeyboard(s, kr8, 8), 1);
CHECK_EQ(drainMouse(s, mr8, 8), 1);
CHECK(keysClear(kr8[0]));
CHECK(mouseClear(mr8[0]));
CHECK_EQ(s.keys.held(), 0);
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("release with no target attached queues nothing to be counted as dropped");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(0, &k, 1);
s.pushKeys();
s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0});
// Neither device reachable: the drain counts whatever is left as dropped, so the
// fail-safe must not add release reports nobody can receive. The state is still
// cleared, which is the part that matters for whatever comes next.
CHECK(!s.releaseAll(false, false));
CHECK_EQ(s.keyQ.count(), 0u);
CHECK_EQ(s.mouseQ.count(), 0u);
CHECK_EQ(s.keys.held(), 0);
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("release cancels a pasted run -- clearing means stop, not pause");
{
OutputStage s;
const uint8_t text[] = {'h', 'e', 'l', 'l', 'o'};
CHECK(s.typer.feed(text, sizeof(text)).result == kr::text::Result::Ok);
s.typer.start(1000, 12);
CHECK(s.typer.active());
CHECK_EQ(s.typer.pending(), 5u);
s.releaseAll(true, true);
CHECK(!s.typer.active());
CHECK_EQ(s.typer.pending(), 0u);
}
CASE("release ends a chord mid-hold");
{
OutputStage s;
s.chord.active = true;
s.chord.releaseAtMs = 999999;
s.releaseAll(true, true);
CHECK(!s.chord.active);
}
CASE("an idle mouse frame costs nothing and is not queued");
{
OutputStage s;
CHECK(s.pushMouse(Frame{0, 0, 0, 0, 0}) == OutputStage::Pushed::Nothing);
CHECK_EQ(s.mouseQ.count(), 0u);
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Ok);
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Nothing);
CHECK_EQ(s.mouseQ.count(), 1u);
}
CASE("a full queue is refused, and says so");
{
OutputStage s;
// Every report differs, so nothing collapses and the queue really fills.
for (size_t i = 0; i < kr::kMouseReportQueueDepth; i++) {
CHECK(s.pushMouse(Frame{0, (int8_t)(i + 1), 0, 0, 0}) == OutputStage::Pushed::Ok);
}
CHECK_EQ(s.mouseQ.count(), (unsigned)kr::kMouseReportQueueDepth);
// Not "drop the movement, it is only a movement": the frame that does not fit may be
// the one releasing a held button, and that is a stuck-mouse failure.
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Overflow);
const uint8_t lo = 0x04, hi = 0x05;
for (size_t i = 0; i < kr::kReportQueueDepth; i++) {
const uint8_t k = (i & 1) ? lo : hi; // alternating: no two neighbours equal
CHECK(s.pushKeyboardReport(kr::makeReport(0, &k, 1)) == OutputStage::Pushed::Ok);
}
CHECK_EQ(s.keyQ.count(), (unsigned)kr::kReportQueueDepth);
const uint8_t other = 0x06;
CHECK(s.pushKeyboardReport(kr::makeReport(0, &other, 1)) ==
OutputStage::Pushed::Overflow);
// Overflow leaves the queue untouched: the caller's response is releaseEverything(),
// and half-dropping a state transition is the thing that must not happen.
CHECK_EQ(s.keyQ.count(), (unsigned)kr::kReportQueueDepth);
CHECK(s.releaseAll(true, true));
CHECK_EQ(s.keyQ.count(), 1u); // ...and the release still gets out
CHECK_EQ(s.mouseQ.count(), 1u);
}
CASE("queue depths are the ones config.h declares");
{
OutputStage s;
CHECK_EQ(s.keyQ.capacity(), (unsigned)kr::kReportQueueDepth);
CHECK_EQ(s.mouseQ.capacity(), (unsigned)kr::kMouseReportQueueDepth);
CHECK(kr::kMouseReportQueueDepth >= 8); // a flick is tens of reports
CHECK(kr::kMouseReportQueueDepth <= 64); // and it is RAM on an 8 MB board
}
CASE("order is preserved across a movement burst under a held button");
{
OutputStage s;
s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0});
s.pushMouse(Frame{kBtnLeft, 10, 0, 0, 0});
s.pushMouse(Frame{kBtnLeft, 10, 0, 0, 0}); // identical neighbour: collapses
s.pushMouse(Frame{kBtnLeft, 7, 0, 0, 0});
s.pushMouse(Frame{0, 0, 0, 0, 0});
MouseReport got[8];
CHECK_EQ(drainMouse(s, got, 8), 4); // 5 frames in, one identical pair out
CHECK_EQ(got[0].buttons, kBtnLeft);
CHECK_EQ(got[0].dx, 0);
CHECK_EQ(got[1].dx, 10);
CHECK_EQ(got[2].dx, 7); // the repeated +10 never arrived twice
CHECK_EQ(got[2].buttons, kBtnLeft);
CHECK(mouseClear(got[3])); // and the release is last
}
return report("output");
}