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
This commit is contained in:
+5
-4
@@ -4,9 +4,10 @@
|
||||
# tests/run_tests.sh build and run every suite
|
||||
# tests/run_tests.sh clean remove the build directory
|
||||
#
|
||||
# The modules under test (src/keystate.h, src/proto.h, src/text.h, src/ws_codec.h,
|
||||
# src/sha1.h) have no Arduino or ESP-IDF dependency by design, so the exact code that
|
||||
# runs on the board is compiled and exercised here before anything is flashed.
|
||||
# The modules under test (src/keystate.h, src/proto.h, src/text.h, src/mouse.h,
|
||||
# src/output.h, src/ws_codec.h, src/sha1.h) have no Arduino or ESP-IDF dependency by
|
||||
# design, so the exact code that runs on the board is compiled and exercised here before
|
||||
# anything is flashed.
|
||||
#
|
||||
# A C++ compiler is not always installed on a workstation, so the runner picks one:
|
||||
# $CXX if you set it, it wins
|
||||
@@ -32,7 +33,7 @@ images=(
|
||||
"docker.io/library/gcc:14"
|
||||
)
|
||||
|
||||
suites=(test_keystate test_proto test_text test_ws_codec)
|
||||
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec)
|
||||
|
||||
build_and_run() {
|
||||
local cxx="$1"
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// tests for the emulated mouse: tests/test_mouse.cpp
|
||||
//
|
||||
// The two things worth proving about a relative mouse are that a frame which changes
|
||||
// nothing sends nothing, and that a button release is never conditional on movement.
|
||||
// Everything else about it is arithmetic.
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
|
||||
#include "mouse.h"
|
||||
#include "test.h"
|
||||
|
||||
using kr::mouse::Button;
|
||||
using kr::mouse::Frame;
|
||||
using kr::mouse::MouseReport;
|
||||
using kr::mouse::MouseState;
|
||||
|
||||
namespace {
|
||||
|
||||
bool allZero(const MouseReport &r) {
|
||||
const uint8_t *b = reinterpret_cast<const uint8_t *>(&r);
|
||||
for (size_t i = 0; i < sizeof(MouseReport); i++) {
|
||||
if (b[i] != 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
CASE("the report is the five bytes the descriptor promises");
|
||||
{
|
||||
CHECK_EQ(sizeof(MouseReport), 5);
|
||||
// The layout is asserted, not assumed: buttons, X, Y, wheel, pan. A struct that
|
||||
// reordered itself would still compile and would move the target's cursor with the
|
||||
// wheel byte.
|
||||
CHECK_EQ(offsetof(MouseReport, buttons), 0);
|
||||
CHECK_EQ(offsetof(MouseReport, dx), 1);
|
||||
CHECK_EQ(offsetof(MouseReport, dy), 2);
|
||||
CHECK_EQ(offsetof(MouseReport, wheel), 3);
|
||||
CHECK_EQ(offsetof(MouseReport, pan), 4);
|
||||
}
|
||||
|
||||
CASE("button bits are the report's order, not the DOM's");
|
||||
{
|
||||
CHECK_EQ(kr::mouse::kBtnLeft, 0x01);
|
||||
CHECK_EQ(kr::mouse::kBtnRight, 0x02);
|
||||
CHECK_EQ(kr::mouse::kBtnMiddle, 0x04);
|
||||
CHECK_EQ(kr::mouse::kBtnBackward, 0x08);
|
||||
CHECK_EQ(kr::mouse::kBtnForward, 0x10);
|
||||
CHECK_EQ(kr::mouse::kBtnAll, 0x1F);
|
||||
}
|
||||
|
||||
CASE("movement produces a report, nothing produces nothing");
|
||||
{
|
||||
MouseState m;
|
||||
MouseReport r;
|
||||
CHECK(m.apply(Frame{0, 30, -4, 0, 0}, r));
|
||||
CHECK_EQ(r.buttons, 0);
|
||||
CHECK_EQ(r.dx, 30);
|
||||
CHECK_EQ(r.dy, -4);
|
||||
CHECK_EQ(r.wheel, 0);
|
||||
CHECK_EQ(r.pan, 0);
|
||||
|
||||
// An idle frame is the whole endpoint for that frame and says nothing, so it is
|
||||
// dropped rather than sent -- this is what stops a chatty controller keeping a
|
||||
// target busy at 125 Hz with a pointer that is not moving.
|
||||
CHECK(!m.apply(Frame{0, 0, 0, 0, 0}, r));
|
||||
CHECK(!m.apply(Frame{0, 0, 0, 0, 0}, r));
|
||||
}
|
||||
|
||||
CASE("a button change is a report even with no movement at all");
|
||||
{
|
||||
MouseState m;
|
||||
MouseReport r;
|
||||
CHECK(m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r));
|
||||
CHECK_EQ(r.buttons, kr::mouse::kBtnLeft);
|
||||
CHECK(allZero(kr::mouse::kReportClear)); // the report a fail-safe sends
|
||||
|
||||
// Repeating the same button byte with no movement is not a new state.
|
||||
CHECK(!m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r));
|
||||
CHECK(m.apply(Frame{0, 0, 0, 0, 0}, r)); // ...but letting go is
|
||||
CHECK_EQ(r.buttons, 0);
|
||||
}
|
||||
|
||||
CASE("buttons are restated, not diffed");
|
||||
{
|
||||
MouseState m;
|
||||
MouseReport r;
|
||||
m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r);
|
||||
m.apply(Frame{kr::mouse::kBtnLeft | kr::mouse::kBtnRight, 0, 0, 0, 0}, r);
|
||||
CHECK_EQ(r.buttons, kr::mouse::kBtnLeft | kr::mouse::kBtnRight);
|
||||
// Letting go of one reports the state that remains, which is the whole reason a
|
||||
// frame lost in the network cannot strand a button: the next frame is the truth.
|
||||
m.apply(Frame{kr::mouse::kBtnRight, 5, 0, 0, 0}, r);
|
||||
CHECK_EQ(r.buttons, kr::mouse::kBtnRight);
|
||||
CHECK_EQ(r.dx, 5);
|
||||
CHECK_EQ(m.buttons(), kr::mouse::kBtnRight);
|
||||
CHECK(m.down());
|
||||
}
|
||||
|
||||
CASE("only the five buttons the report has");
|
||||
{
|
||||
MouseState m;
|
||||
MouseReport r;
|
||||
// The parser refuses a sixth bit, so this is a second line of defence rather than
|
||||
// the behaviour anyone relies on: whatever arrives cannot set a padding bit.
|
||||
m.apply(Frame{0xFF, 1, 0, 0, 0}, r);
|
||||
CHECK_EQ(r.buttons, 0x1F);
|
||||
CHECK_EQ(m.buttons(), 0x1F);
|
||||
}
|
||||
|
||||
CASE("releaseAll lets go, from any state, and is idempotent");
|
||||
{
|
||||
MouseState m;
|
||||
MouseReport r;
|
||||
// Nothing was ever down; letting go is still well-defined and leaves the same state.
|
||||
m.releaseAll();
|
||||
CHECK_EQ(m.buttons(), 0);
|
||||
CHECK(!m.down());
|
||||
|
||||
m.apply(Frame{kr::mouse::kBtnMiddle, 0, 0, 0, 0}, r);
|
||||
m.releaseAll();
|
||||
CHECK_EQ(m.buttons(), 0);
|
||||
CHECK(!m.down());
|
||||
m.releaseAll();
|
||||
CHECK_EQ(m.buttons(), 0);
|
||||
|
||||
// And after letting go, the next movement frame restates the empty button byte -- so
|
||||
// a release that never reached the target is corrected by the next one regardless.
|
||||
CHECK(m.apply(Frame{0, 4, 0, 0, 0}, r));
|
||||
CHECK_EQ(r.buttons, 0);
|
||||
}
|
||||
|
||||
CASE("the full range of one signed byte survives");
|
||||
{
|
||||
MouseState m;
|
||||
MouseReport r;
|
||||
CHECK(m.apply(Frame{0, 127, -127, 127, -127}, r));
|
||||
CHECK_EQ(r.dx, 127);
|
||||
CHECK_EQ(r.dy, -127);
|
||||
CHECK_EQ(r.wheel, 127);
|
||||
CHECK_EQ(r.pan, -127);
|
||||
}
|
||||
|
||||
CASE("report equality is byte equality");
|
||||
{
|
||||
const MouseReport a{1, 2, 3, 4, 5};
|
||||
const MouseReport b{1, 2, 3, 4, 5};
|
||||
const MouseReport c{1, 2, 3, 4, 6};
|
||||
const MouseReport d{0, 2, 3, 4, 5};
|
||||
CHECK(kr::mouse::reportsEqual(a, b));
|
||||
CHECK(!kr::mouse::reportsEqual(a, c)); // pan differs: still a different report
|
||||
CHECK(!kr::mouse::reportsEqual(a, d));
|
||||
}
|
||||
|
||||
return report("mouse");
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// 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");
|
||||
}
|
||||
+227
-9
@@ -37,11 +37,33 @@ function makeEl(tagName) {
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; },
|
||||
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
|
||||
// Element handlers are reachable too: the mouse pad is clicked, and a real click is
|
||||
// what the browser turns into a Pointer Lock request.
|
||||
fire(t, ev) {
|
||||
for (const fn of (this.listeners[t] || [])) fn(ev || { preventDefault() {} });
|
||||
},
|
||||
getAttribute() { return null; }, setAttribute() {}, focus() {},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
// Document- and window-level listeners are recorded rather than dropped. Every
|
||||
// fail-safe this page has is triggered by something the browser does to the document --
|
||||
// blur, visibilitychange, pointerlockchange -- so a shim that swallows them cannot
|
||||
// prove them.
|
||||
function makeListeners() {
|
||||
const map = {};
|
||||
return {
|
||||
map,
|
||||
add(t, fn) { (map[t] = map[t] || []).push(fn); },
|
||||
fire(t, ev) {
|
||||
for (const fn of (map[t] || [])) fn(ev || { preventDefault() {}, target: null });
|
||||
},
|
||||
};
|
||||
}
|
||||
const docListeners = makeListeners();
|
||||
const winListeners = makeListeners();
|
||||
|
||||
// The shim hands out an element for any id, which is far less work than a real DOM.
|
||||
// The weakness in that is obvious, so it is compensated for: every id the script asks
|
||||
// for is recorded and checked against the ids that are actually in the markup at the
|
||||
@@ -70,16 +92,26 @@ class FakeWebSocket {
|
||||
FakeWebSocket.OPEN = 1;
|
||||
FakeWebSocket.instances = [];
|
||||
|
||||
const sandbox = {
|
||||
document: {
|
||||
getElementById: byId,
|
||||
createElement: t => makeEl(t),
|
||||
querySelectorAll: () => [],
|
||||
addEventListener() {},
|
||||
body: makeEl("body"),
|
||||
hidden: false,
|
||||
const doc = {
|
||||
getElementById: byId,
|
||||
createElement: t => makeEl(t),
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: docListeners.add,
|
||||
body: makeEl("body"),
|
||||
hidden: false,
|
||||
// Pointer Lock, as much of it as the page touches. exitPointerLock fires the change
|
||||
// event because a real browser does, which is what makes the release path testable.
|
||||
pointerLockElement: null,
|
||||
exitPointerLock() {
|
||||
if (!doc.pointerLockElement) return;
|
||||
doc.pointerLockElement = null;
|
||||
docListeners.fire("pointerlockchange", {});
|
||||
},
|
||||
window: { addEventListener() {} },
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
document: doc,
|
||||
window: { addEventListener: winListeners.add },
|
||||
location: { protocol: "http:", host: "keyrelay.local" },
|
||||
sessionStorage: { getItem: () => null, setItem() {} },
|
||||
WebSocket: FakeWebSocket,
|
||||
@@ -95,6 +127,11 @@ const expose = `
|
||||
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
|
||||
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect,
|
||||
TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS,
|
||||
takeMouse, dropMouse, flushMouse, mouseFrame, paintMouse, refreshMouseUi,
|
||||
MOUSE_DELTA_MAX, MOUSE_FRAME_MS, MOUSE_MAX_FRAMES, MOUSE_WHEEL_PX,
|
||||
get mCaptured() { return mCaptured; }, set mCaptured(v) { mCaptured = v; },
|
||||
get mButtons() { return mButtons; },
|
||||
get pendX() { return pendX; }, get pendY() { return pendY; },
|
||||
get ws() { return ws; },
|
||||
get armed() { return armed; }, set armed(v) { armed = v; },
|
||||
get authed() { return authed; }, set authed(v) { authed = v; },
|
||||
@@ -131,7 +168,29 @@ const checkEq = (a, b, label) => {
|
||||
}
|
||||
};
|
||||
const frames = () => sentFrames.filter(f => f.startsWith("P="));
|
||||
const mframes = () => sentFrames.filter(f => f.startsWith("M="));
|
||||
const textOf = f => Buffer.from(f.slice(f.indexOf(":") + 1), "hex").toString();
|
||||
|
||||
// The pad the page asks for Pointer Lock, wired the way a browser wires it: the request
|
||||
// sets pointerLockElement and fires pointerlockchange.
|
||||
function givePointerLock() {
|
||||
const pad = byId("mPad");
|
||||
pad.requestPointerLock = () => {
|
||||
doc.pointerLockElement = pad;
|
||||
docListeners.fire("pointerlockchange", {});
|
||||
return Promise.resolve();
|
||||
};
|
||||
return pad;
|
||||
}
|
||||
function takePointerLockAway() {
|
||||
doc.pointerLockElement = null;
|
||||
docListeners.fire("pointerlockchange", {});
|
||||
}
|
||||
const move = (dx, dy) => docListeners.fire("mousemove", { movementX: dx, movementY: dy });
|
||||
const btn = (b, down) => docListeners.fire(down ? "mousedown" : "mouseup",
|
||||
{ button: b, preventDefault() {} });
|
||||
const scroll = (dy, dx, mode) => docListeners.fire("wheel",
|
||||
{ deltaY: dy, deltaX: dx || 0, deltaMode: mode || 0, preventDefault() {} });
|
||||
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
console.log("the log stays readable");
|
||||
@@ -264,6 +323,165 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
|
||||
await p;
|
||||
checkEq(frames().length, 1, "a disarm mid-run stops the run");
|
||||
|
||||
console.log("the mouse");
|
||||
givePointerLock();
|
||||
x.setArmed(true, "harness: armed");
|
||||
x.authed = true;
|
||||
|
||||
// A capture with nothing behind it must not produce a single frame.
|
||||
x.setArmed(false, "harness: disarmed");
|
||||
sentFrames.length = 0;
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
checkEq(mframes().length, 0, "a disarmed bridge is not offered the mouse");
|
||||
checkEq(x.mCaptured, false, "and the pointer is not taken");
|
||||
|
||||
x.setArmed(true, "harness: armed");
|
||||
sentFrames.length = 0;
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
checkEq(x.mCaptured, true, "capture takes the pointer when the bridge is armed");
|
||||
checkEq(mframes().length, 0, "taking the pointer sends no movement nobody made");
|
||||
|
||||
move(30, -4);
|
||||
sentFrames.length = 0;
|
||||
x.flushMouse();
|
||||
checkEq(mframes().length, 1, "one flush of one small movement is one frame");
|
||||
checkEq(mframes()[0], "M=00:30:-4:0:0", "the button byte first, then this frame's movement");
|
||||
|
||||
sentFrames.length = 0;
|
||||
x.flushMouse();
|
||||
checkEq(mframes().length, 0, "a frame that says nothing is not sent at all");
|
||||
|
||||
// A fast flick: the report carries one signed byte per axis, so the tab splits it.
|
||||
move(300, -260);
|
||||
sentFrames.length = 0;
|
||||
x.flushMouse();
|
||||
const big = mframes();
|
||||
const sum = i => big.reduce((a, f) => a + Number(f.split(":")[i]), 0);
|
||||
checkEq(sum(1), 300, "300 px of X arrives as 300 px, split across reports");
|
||||
checkEq(sum(2), -260, "and so does the Y");
|
||||
check(big.every(f => [1, 2, 3, 4].every(i => Math.abs(Number(f.split(":")[i]))
|
||||
<= x.MOUSE_DELTA_MAX)),
|
||||
"and no single report asks for more than one signed byte");
|
||||
|
||||
// Order is the whole difference between a click and a drag.
|
||||
move(12, 0);
|
||||
sentFrames.length = 0;
|
||||
btn(0, true);
|
||||
const order = sentFrames.filter(f => f.startsWith("M="));
|
||||
checkEq(order.length, 2, "a press with movement behind it is two frames");
|
||||
checkEq(order[0], "M=00:12:0:0:0", "the movement first, under the old button state");
|
||||
checkEq(order[1], "M=01:0:0:0:0", "then the button, so the target sees press then move");
|
||||
checkEq(x.mButtons, 0x01, "left is held");
|
||||
|
||||
sentFrames.length = 0;
|
||||
btn(0, false);
|
||||
checkEq(mframes()[0], "M=00:0:0:0:0", "release restates the whole button byte as nothing");
|
||||
checkEq(x.mButtons, 0, "and the tab agrees");
|
||||
|
||||
// Two buttons at once, and letting go of one: the byte that goes out is the state,
|
||||
// not the change. (DOM button 0 = left -> 0x01, 2 = right -> 0x02, 1 = middle -> 0x04.)
|
||||
btn(0, true);
|
||||
btn(2, true);
|
||||
sentFrames.length = 0;
|
||||
btn(0, false);
|
||||
checkEq(mframes()[0], "M=02:0:0:0:0", "right stays down when the left button goes up");
|
||||
|
||||
sentFrames.length = 0;
|
||||
scroll(100, 0); // one detent's worth of pixels
|
||||
scroll(100, 0);
|
||||
scroll(25, 0); // not yet a detent: carried, not dropped
|
||||
x.flushMouse();
|
||||
checkEq(mframes()[0], "M=02:0:0:2:0", "wheel travels in detents and the remainder waits");
|
||||
|
||||
sentFrames.length = 0;
|
||||
scroll(3, 0, 1); // deltaMode 1: lines, one detent each
|
||||
x.flushMouse();
|
||||
checkEq(mframes()[0], "M=02:0:0:3:0", "a line is a detent, whatever the pixel density");
|
||||
|
||||
// The fail-safes. Each one has to let go of the buttons, whatever else it does.
|
||||
btn(2, false);
|
||||
btn(0, true);
|
||||
checkEq(x.mButtons, 0x01, "holding left again for the release tests");
|
||||
sentFrames.length = 0;
|
||||
takePointerLockAway();
|
||||
checkEq(mframes()[0], "M=00:0:0:0:0",
|
||||
"losing the pointer lock (Esc, focus, the browser) releases every button");
|
||||
checkEq(x.mCaptured, false, "and the tab stops capturing");
|
||||
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
btn(1, true);
|
||||
sentFrames.length = 0;
|
||||
doc.pointerLockElement = null; // the browser already dropped it; blur follows
|
||||
winListeners.fire("blur", {});
|
||||
check(mframes().includes("M=00:0:0:0:0"),
|
||||
"a blur releases the buttons too");
|
||||
checkEq(x.mCaptured, false, "and ends the capture");
|
||||
|
||||
// The stay-armed-on-blur option is an exception for the keyboard and must not become
|
||||
// one for the cursor: a pointer nobody is looking at is not a pointer being aimed.
|
||||
byId("keepArmed").checked = true;
|
||||
x.setArmed(true, "harness: armed");
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
btn(0, true);
|
||||
sentFrames.length = 0;
|
||||
winListeners.fire("blur", {});
|
||||
check(mframes().includes("M=00:0:0:0:0"),
|
||||
"stay-armed-on-blur keeps the keyboard armed and still releases the mouse");
|
||||
checkEq(x.mCaptured, false, "the capture does not survive it either");
|
||||
byId("keepArmed").checked = false;
|
||||
|
||||
x.setArmed(true, "harness: armed");
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
btn(0, true);
|
||||
sentFrames.length = 0;
|
||||
x.setArmed(false, "harness: disarmed with a button held");
|
||||
checkEq(x.mCaptured, false, "disarm ends the capture");
|
||||
checkEq(x.mButtons, 0, "and the tab's own button state");
|
||||
// The board releases the buttons itself when it disarms (src/output.h), so the tab
|
||||
// sends an M= frame to a bridge that has already answered: silence here is correct.
|
||||
checkEq(mframes().length, 0,
|
||||
"disarm does not beg a bridge that has already let go");
|
||||
|
||||
// A board that cannot report a mouse: refused up front, and a running capture ended.
|
||||
x.setArmed(true, "harness: armed");
|
||||
sentFrames.length = 0;
|
||||
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
checkEq(x.mCaptured, false, "a board with no mouse interface does not take the pointer");
|
||||
checkEq(mframes().length, 0, "and gets no frames");
|
||||
checkEq(byId("mCapture").disabled, true, "the button says so by being disabled");
|
||||
|
||||
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":1}');
|
||||
checkEq(byId("mCapture").disabled, false,
|
||||
"the button comes back when the board says it can report a mouse");
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
checkEq(x.mCaptured, true, "and it captures again once the board can report one");
|
||||
sentFrames.length = 0;
|
||||
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
|
||||
checkEq(x.mCaptured, false,
|
||||
"a capture that the board can no longer honour is ended, not left hanging");
|
||||
|
||||
// Every frame the tab can emit has to be one the firmware parses: five fields, a hex
|
||||
// button byte, signed decimal movements, and short enough for one WebSocket message.
|
||||
const shape = /^M=[0-9a-f]{2}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}$/;
|
||||
let malformed = 0, tooLong = 0;
|
||||
for (const f of mframes()) {
|
||||
if (!shape.test(f)) malformed++;
|
||||
if (f.length > 256) tooLong++;
|
||||
}
|
||||
checkEq(malformed, 0, "every mouse frame this tab emitted parses");
|
||||
checkEq(tooLong, 0, "and every one fits in one WebSocket message");
|
||||
|
||||
x.mCaptured = false;
|
||||
x.setArmed(false, "harness: done");
|
||||
|
||||
console.log("element ids");
|
||||
const markup = fs.readFileSync(headerPath, "utf8");
|
||||
const present = new Set();
|
||||
|
||||
@@ -343,6 +343,135 @@ int main() {
|
||||
CHECK(html.find("data-chord=\"05:04\"") == std::string::npos);
|
||||
}
|
||||
|
||||
CASE("mouse message: the button byte restated, plus one report's movement");
|
||||
{
|
||||
Message m;
|
||||
CHECK(parse("M=00:0:0:0:0", m) == Cmd::Mouse);
|
||||
CHECK_EQ(m.buttons, 0);
|
||||
CHECK_EQ(m.dx, 0);
|
||||
|
||||
CHECK(parse("M=01:30:-4:0:0", m) == Cmd::Mouse);
|
||||
CHECK_EQ(m.buttons, 1);
|
||||
CHECK_EQ(m.dx, 30);
|
||||
CHECK_EQ(m.dy, -4);
|
||||
|
||||
// Every field, at both ends of the range a signed byte carries.
|
||||
CHECK(parse("M=1f:127:-127:127:-127", m) == Cmd::Mouse);
|
||||
CHECK_EQ(m.buttons, 0x1f);
|
||||
CHECK_EQ(m.dx, 127);
|
||||
CHECK_EQ(m.dy, -127);
|
||||
CHECK_EQ(m.wheel, 127);
|
||||
CHECK_EQ(m.pan, -127);
|
||||
|
||||
CHECK(parse("M=15:0:0:0:0", m) == Cmd::Mouse);
|
||||
CHECK_EQ(m.buttons, 0x15);
|
||||
}
|
||||
|
||||
CASE("a mouse frame that is not exactly one report is refused");
|
||||
{
|
||||
Message m;
|
||||
// Rejecting means nothing moves, which for a movement is harmless and for a button
|
||||
// byte is the only safe answer. Guessing -- clamping, or splitting a big delta into
|
||||
// several reports -- would make the board responsible for a number the controller
|
||||
// got wrong.
|
||||
CHECK(parse("M") == Cmd::Bad);
|
||||
CHECK(parse("M=") == Cmd::Bad);
|
||||
CHECK(parse("M=00") == Cmd::Bad);
|
||||
CHECK(parse("M=00:") == Cmd::Bad);
|
||||
CHECK(parse("M=00:0") == Cmd::Bad);
|
||||
CHECK(parse("M=00:0:0:0") == Cmd::Bad); // four fields is not five
|
||||
CHECK(parse("M=00:0:0:0:0:") == Cmd::Bad); // trailing delimiter
|
||||
CHECK(parse("M=00:0:0:0:0:0") == Cmd::Bad); // ...nor a sixth one
|
||||
CHECK(parse("M=0:0:0:0:0") == Cmd::Bad); // the button byte is two hex digits
|
||||
CHECK(parse("M=20:0:0:0:0") == Cmd::Bad); // a sixth button has nowhere to go
|
||||
CHECK(parse("M=ff:0:0:0:0") == Cmd::Bad);
|
||||
CHECK(parse("M=00:128:0:0:0") == Cmd::Bad); // more than one report's worth
|
||||
CHECK(parse("M=00:-128:0:0:0") == Cmd::Bad);
|
||||
CHECK(parse("M=00:0:128:0:0") == Cmd::Bad);
|
||||
CHECK(parse("M=00:+5:0:0:0") == Cmd::Bad); // no plus sign, whatever JS emits
|
||||
CHECK(parse("M=00:5 :0:0:0") == Cmd::Bad); // no spaces
|
||||
CHECK(parse("M=00:5x:0:0:0") == Cmd::Bad);
|
||||
CHECK(parse("M=00::0:0:0") == Cmd::Bad); // an empty field is not a zero
|
||||
CHECK(parse("m=00:0:0:0:0") == Cmd::Bad); // commands are uppercase
|
||||
CHECK(parse("M=00:0:0:0:0 ") == Cmd::Bad); // nothing trails a command
|
||||
CHECK(parse("M=00:0:0:0:0\n") == Cmd::Mouse); // ...except a hand-typed newline
|
||||
// -0 is a hand-typed oddity, not an ambiguity.
|
||||
CHECK(parse("M=00:-0:0:0:0", m) == Cmd::Mouse);
|
||||
CHECK_EQ(m.dx, 0);
|
||||
}
|
||||
|
||||
CASE("the board refuses to render a mouse frame it would not parse");
|
||||
{
|
||||
char buf[64];
|
||||
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 0, 0, 0, 0), 12u);
|
||||
CHECK_STR(buf, "M=00:0:0:0:0");
|
||||
CHECK(kr::proto::formatMouse(buf, sizeof(buf), 0x01, 30, -4, 0, 0) > 0);
|
||||
CHECK_STR(buf, "M=01:30:-4:0:0");
|
||||
CHECK(kr::proto::formatMouse(buf, sizeof(buf), 0x1f, -127, 127, -127, 127) > 0);
|
||||
CHECK_STR(buf, "M=1f:-127:127:-127:127");
|
||||
|
||||
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0x20, 0, 0, 0, 0), 0u);
|
||||
CHECK_STR(buf, ""); // nothing half-written
|
||||
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 128, 0, 0, 0), 0u);
|
||||
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 0, 0, 0, -128), 0u);
|
||||
|
||||
// A buffer that is too small yields 0 and an empty string, never a truncated frame:
|
||||
// a frame cut in half is a command the board did not receive, on a target that is
|
||||
// waiting for the rest of it.
|
||||
char tiny[12];
|
||||
CHECK_EQ(kr::proto::formatMouse(tiny, sizeof(tiny), 0, 127, 127, 127, 127), 0u);
|
||||
CHECK_STR(tiny, "");
|
||||
}
|
||||
|
||||
CASE("a rendered mouse frame parses back to the same report");
|
||||
{
|
||||
const uint8_t bs[] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x1f};
|
||||
const int8_t vs[] = {0, 1, -1, 127, -127, 42, -42};
|
||||
for (size_t b = 0; b < sizeof(bs); b++) {
|
||||
for (size_t i = 0; i < sizeof(vs); i++) {
|
||||
for (size_t j = 0; j < sizeof(vs); j++) {
|
||||
char buf[64];
|
||||
const size_t n = kr::proto::formatMouse(buf, sizeof(buf), bs[b], vs[i], vs[j],
|
||||
(int8_t)(-(int)vs[i]), vs[j]);
|
||||
if (n == 0) { CHECK(false); continue; }
|
||||
Message m;
|
||||
CHECK(kr::proto::parse(buf, n, m) == Cmd::Mouse);
|
||||
CHECK_EQ(m.buttons, bs[b]);
|
||||
CHECK_EQ(m.dx, vs[i]);
|
||||
CHECK_EQ(m.dy, vs[j]);
|
||||
CHECK_EQ(m.wheel, -vs[i]);
|
||||
CHECK_EQ(m.pan, vs[j]);
|
||||
CHECK(n <= kr::kWsMessageCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CASE("error codes above 9 are not truncated to their last digit");
|
||||
{
|
||||
char buf[96];
|
||||
// The single-digit codes must render exactly as they always did, or every client
|
||||
// that branches on them breaks.
|
||||
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 1, "x"), 4u);
|
||||
CHECK_STR(buf, "E1:x");
|
||||
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 9, "no key"), 9u);
|
||||
CHECK_STR(buf, "E9:no key");
|
||||
// 10 and 11 are the mouse's, and `code % 10` used to turn them into E0 -- a code no
|
||||
// client would recognise, on the one path where a client needs to know.
|
||||
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 10, "off"), 7u);
|
||||
CHECK_STR(buf, "E10:off");
|
||||
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 11, "boot"), 8u);
|
||||
CHECK_STR(buf, "E11:boot");
|
||||
|
||||
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 0, "z"), 4u);
|
||||
CHECK_STR(buf, "E0:z");
|
||||
// Never a truncated frame: a client cannot parse half of one, and cannot tell an
|
||||
// over-long error from a real one.
|
||||
CHECK_EQ(kr::proto::formatError(buf, 7, 10, "too long"), 0u);
|
||||
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 10, ""), 4u);
|
||||
CHECK_STR(buf, "E10:");
|
||||
}
|
||||
|
||||
CASE("the page's text constants are the board's text constants");
|
||||
{
|
||||
// The page splits a paste by kTextFrameMax and refuses more than kTextJobCap,
|
||||
@@ -363,5 +492,48 @@ int main() {
|
||||
CHECK(html.find("\"P=\" + TEXT_PACE_MS") != std::string::npos);
|
||||
}
|
||||
|
||||
CASE("the page's mouse constants are the board's mouse constants");
|
||||
{
|
||||
// Same hand-copied-number hazard as the text ones: if the page thinks one report
|
||||
// carries 255 of something, the board refuses every frame at 128.
|
||||
std::string html = readFile("src/web_page.h");
|
||||
if (html.empty()) html = readFile("../src/web_page.h");
|
||||
CHECK(!html.empty());
|
||||
|
||||
char needle[80];
|
||||
snprintf(needle, sizeof(needle), "const MOUSE_DELTA_MAX = %d;", (int)kr::kMouseDeltaMax);
|
||||
CHECK(html.find(needle) != std::string::npos);
|
||||
snprintf(needle, sizeof(needle), "const MOUSE_BUTTON_MASK = 0x%x;",
|
||||
(unsigned)kr::kMouseButtonMask);
|
||||
CHECK(html.find(needle) != std::string::npos);
|
||||
|
||||
// The page must build the frame the firmware parses, with all five fields -- a
|
||||
// missing one is a refused frame, and the page would look like it was moving the
|
||||
// mouse while nothing arrived.
|
||||
CHECK(html.find("function mouseFrame(buttons, dx, dy, wheel = 0, pan = 0)") !=
|
||||
std::string::npos);
|
||||
CHECK(html.find("\":\" + dx + \":\" + dy + \":\" + wheel + \":\" + pan") !=
|
||||
std::string::npos);
|
||||
|
||||
// DOM MouseEvent.button -> report bit. The DOM numbers are not in HID order: 1 is
|
||||
// the middle button and 2 is the right one. A page that got these the wrong way round
|
||||
// would send a right-click as a middle-click, parse cleanly, and be wrong -- the
|
||||
// Alt+Tab failure all over again, so the mapping is pinned here and in relayctl.
|
||||
CHECK(html.find("0: 0x01, 2: 0x02, 1: 0x04, 3: 0x08, 4: 0x10") != std::string::npos);
|
||||
|
||||
// The mouse has to be released by the same fail-safes the keys are.
|
||||
CHECK(html.find("dropMouse(\"disarmed\")") != std::string::npos);
|
||||
CHECK(html.find("dropMouse(\"tab lost focus\")") != std::string::npos);
|
||||
CHECK(html.find("dropEverything(\"tab hidden\")") != std::string::npos);
|
||||
// ...and the board's own release funnel must be the one that releases the buttons
|
||||
// when the page could not.
|
||||
std::string out = readFile("src/output.h");
|
||||
if (out.empty()) out = readFile("../src/output.h");
|
||||
CHECK(!out.empty());
|
||||
CHECK(out.find("mouseState.releaseAll()") != std::string::npos);
|
||||
CHECK(out.find("mouse::kReportClear") != std::string::npos);
|
||||
CHECK(out.find("typer.cancel()") != std::string::npos);
|
||||
}
|
||||
|
||||
return report("proto");
|
||||
}
|
||||
|
||||
+206
-3
@@ -201,8 +201,10 @@ config_src = (root / "src" / "config.h").read_text()
|
||||
|
||||
|
||||
def config_value(name):
|
||||
found = re.search(rf"\b{name}\s*=\s*(\d+)\s*;", config_src)
|
||||
return int(found.group(1)) if found else None
|
||||
# Decimal or hex: kMouseDeltaMax is 127 and kMouseButtonMask is 0x1F, and both are
|
||||
# numbers a client has to agree with the firmware about.
|
||||
found = re.search(rf"\b{name}\s*=\s*(0[xX][0-9a-fA-F]+|\d+)\s*;", config_src)
|
||||
return int(found.group(1), 0) if found else None
|
||||
|
||||
|
||||
check_eq(relayctl.TEXT_FRAME_MAX, config_value("kTextFrameMax"),
|
||||
@@ -228,6 +230,89 @@ src_lines = inspect.getsource(drained)
|
||||
check("OP_PONG" in src_lines, "_drain answers a ping")
|
||||
check("raise RelayError" in src_lines, "_drain still surfaces an error frame")
|
||||
|
||||
print("mouse")
|
||||
check_eq(relayctl.mouse_frame(0x01, 30, -4), "M=01:30:-4:0:0",
|
||||
"a mouse frame is the button byte then four movements")
|
||||
check_eq(relayctl.mouse_frame(0), "M=00:0:0:0:0",
|
||||
"and all five fields are always written -- the firmware parses exactly five")
|
||||
check_eq(relayctl.mouse_frame(0x1f, -127, 127, -127, 127), "M=1f:-127:127:-127:127",
|
||||
"the full signed range")
|
||||
|
||||
for bad, why in (((0x20, 0, 0, 0, 0), "a sixth button bit has nowhere to go"),
|
||||
((0, 128, 0, 0, 0), "128 is more than one report"),
|
||||
((0, -128, 0, 0, 0), "and so is -128"),
|
||||
((0, 0, 0, 128, 0), "the wheel is one byte too")):
|
||||
try:
|
||||
relayctl.mouse_frame(*bad)
|
||||
check(False, f"mouse_frame should refuse: {why}")
|
||||
except relayctl.RelayError:
|
||||
check(True, f"mouse_frame refuses: {why}")
|
||||
|
||||
# A movement bigger than one report is split here, exactly as the page splits it, and the
|
||||
# split is exact -- clamping it would move the cursor half as far as was asked and say
|
||||
# nothing about it.
|
||||
for dx, dy in ((300, 0), (0, -260), (300, -260), (127, 127), (1, -1), (-500, 500)):
|
||||
parts = relayctl.split_moves(dx, dy)
|
||||
check_eq(sum(p[0] for p in parts), dx, f"the X split of {dx},{dy} adds up exactly")
|
||||
check_eq(sum(p[1] for p in parts), dy, f"and the Y of {dx},{dy}")
|
||||
check(all(abs(p[0]) <= relayctl.MOUSE_DELTA_MAX and abs(p[1]) <= relayctl.MOUSE_DELTA_MAX
|
||||
for p in parts), f"and no part of {dx},{dy} exceeds one report")
|
||||
|
||||
check_eq(relayctl.mouse_frames("click", [], "left"),
|
||||
[(0x01, 0, 0, 0, 0), (0, 0, 0, 0, 0)],
|
||||
"a click is the button restated down then restated up")
|
||||
check_eq(relayctl.mouse_frames("release", []), [(0, 0, 0, 0, 0)],
|
||||
"release restates the whole button byte as nothing")
|
||||
drag = relayctl.mouse_frames("drag", ["300", "0"], "right")
|
||||
check_eq(drag[0], (0x02, 0, 0, 0, 0), "a drag presses first")
|
||||
check(drag[-1] == (0, 0, 0, 0, 0), "and releases last")
|
||||
check(all(f[0] == 0x02 for f in drag[1:-1]),
|
||||
"and every movement in between carries the button, so it is a drag and not a move")
|
||||
check_eq(sum(f[1] for f in drag), 300, "and the drag travels the whole distance")
|
||||
check_eq(relayctl.mouse_frames("wheel", ["-3"]), [(0, 0, 0, -3, 0)],
|
||||
"wheel notches go where the report's wheel byte is")
|
||||
for bad_args, why in ((("move", ["1"], "left"), "move needs dx and dy"),
|
||||
(("wheel", [], "left"), "wheel needs a count"),
|
||||
(("move", ["1", "2"], "thumb"), "an unknown button is refused"),
|
||||
(("wiggle", [], "left"), "an unknown action is refused")):
|
||||
try:
|
||||
relayctl.mouse_frames(*bad_args)
|
||||
check(False, f"mouse_frames should refuse: {why}")
|
||||
except (relayctl.RelayError, ValueError):
|
||||
check(True, f"mouse_frames refuses: {why}")
|
||||
|
||||
# Every frame this tool can build must be one the firmware parses. The shape is asserted
|
||||
# rather than a reimplementation of the parser, because the parser itself is tested
|
||||
# against the same frames in tests/test_proto.cpp.
|
||||
mframe = re.compile(r"^M=[0-9a-f]{2}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}$")
|
||||
built = [relayctl.mouse_frame(*f)
|
||||
for f in relayctl.mouse_frames("drag", ["500", "-500", "4", "-4"], "middle")]
|
||||
check(all(mframe.match(f) for f in built), f"every built frame parses ({len(built)})")
|
||||
check(all(len(f) <= config_value("kWsMessageCap") for f in built),
|
||||
"and every one fits in one WebSocket message")
|
||||
|
||||
# The numbers are the board's. If they drift, the board starts answering E6 to frames
|
||||
# this tool believed were legal.
|
||||
check_eq(relayctl.MOUSE_DELTA_MAX, config_value("kMouseDeltaMax"),
|
||||
"relayctl MOUSE_DELTA_MAX == kMouseDeltaMax")
|
||||
check_eq(relayctl.MOUSE_BUTTON_MASK, config_value("kMouseButtonMask"),
|
||||
"relayctl MOUSE_BUTTON_MASK == kMouseButtonMask")
|
||||
check_eq(int(re.search(r"const MOUSE_DELTA_MAX = (\d+);", page_src).group(1)),
|
||||
config_value("kMouseDeltaMax"), "page MOUSE_DELTA_MAX == kMouseDeltaMax")
|
||||
check_eq(int(re.search(r"const MOUSE_BUTTON_MASK = (0x[0-9a-f]+);", page_src).group(1), 16),
|
||||
config_value("kMouseButtonMask"), "page MOUSE_BUTTON_MASK == kMouseButtonMask")
|
||||
check_eq(int(re.search(r"const MOUSE_FRAME_MS = (\d+);", page_src).group(1)),
|
||||
relayctl.MOUSE_FRAME_MS, "the page and the CLI pace mouse frames identically")
|
||||
|
||||
# Button bits, agreed between the page and this tool even though the page keys them by
|
||||
# DOM number and this keys them by name. Getting one wrong is a clean frame that is the
|
||||
# wrong button -- the Alt+Tab failure again, one layer down.
|
||||
check_eq({name: bits for name, bits in relayctl.MOUSE_BUTTONS.items()},
|
||||
{"left": 0x01, "right": 0x02, "middle": 0x04, "back": 0x08, "forward": 0x10},
|
||||
"the button bits are the report descriptor's order")
|
||||
check("0: 0x01, 2: 0x02, 1: 0x04, 3: 0x08, 4: 0x10" in page_src,
|
||||
"the page maps DOM button numbers to those same bits (DOM 1 is middle, 2 is right)")
|
||||
|
||||
print("protocol strings")
|
||||
|
||||
check_eq("S=%02x:%s" % (0x0A, "04,28"), "S=0a:04,28", "state frame format")
|
||||
@@ -239,10 +324,128 @@ check_eq("T" + "hunter2", "Thunter2", "auth frame format")
|
||||
# has to be a deliberate edit in two places.
|
||||
for command in ("A1", "A0", "R", "H", "K"):
|
||||
check(f'"{command}"' in relay_src, f'relayctl sends "{command}"')
|
||||
for command in ("'A'", "'R'", "'H'", "'K'", "'T'", "'S'", "'Y'", "'C'", "'P'"):
|
||||
for command in ("'A'", "'R'", "'H'", "'K'", "'T'", "'S'", "'Y'", "'C'", "'P'", "'M'"):
|
||||
check(command in proto_src, f"proto.h parses {command}")
|
||||
check("case 'M':" in proto_src, "proto.h routes M= to the mouse parser")
|
||||
|
||||
# The two mouse error codes have to be in the README's list. A client that cannot look up
|
||||
# what E11 means cannot tell the operator why the pointer is not moving, and this suite is
|
||||
# the only place that notices when the code and the document stop agreeing.
|
||||
readme_src = (root / "README.md").read_text()
|
||||
for code, where in (("10 the mouse interface is off", "proto.h"),
|
||||
("11 the target asked for boot protocol", "proto.h")):
|
||||
check(code in proto_src, f"proto.h's error list documents {code}")
|
||||
check(code.split()[0] in readme_src,
|
||||
f"README's error list has code {code.split()[0]}")
|
||||
check("mouse interface is off" in readme_src and "boot protocol" in readme_src,
|
||||
"README explains what both mouse refusals mean, not just their numbers")
|
||||
check("`M=<btn>`" in readme_src or "M=<btn>" in readme_src or "`M=`" in readme_src,
|
||||
"README's wire protocol table lists the M= command")
|
||||
check("if (c == 'Y' && n == 0 && out.mods == 0)" in proto_src,
|
||||
"proto.h still requires a chord to press something")
|
||||
|
||||
print("the fail-safe wiring")
|
||||
# Source pins, and deliberately honest about being source pins: control_server.cpp and
|
||||
# main.cpp need a socket and a board, so nothing here runs them. What they were caught
|
||||
# doing is invisible to a compiler and to every behavioural test on the host, and each of
|
||||
# these three is a sentence that a later edit can quietly undo.
|
||||
main_src = (root / "src" / "main.cpp").read_text()
|
||||
server_src = (root / "src" / "control_server.cpp").read_text()
|
||||
|
||||
# The backstop must not ask whether a controller is linked: a controller that has gone
|
||||
# away is exactly the case where linked() is false, which made the silence timer
|
||||
# uncallable in the one situation it exists for -- the board sat armed with a key held
|
||||
# forever, and read-only /status traffic kept it that way.
|
||||
check("if (armed && control.controllerSeen() &&" in main_src,
|
||||
"loop()'s silence disarm runs off controllerSeen(), not linked()")
|
||||
check("if (armed && control.linked()" not in main_src,
|
||||
"nothing gates an armed-and-silent disarm on the link still being there")
|
||||
|
||||
# Accepting a TCP connection is not the operator being alive.
|
||||
accept = server_src[server_src.index("void ControlServer::acceptNew"):
|
||||
server_src.index("size_t ControlServer::readSome")]
|
||||
# Code lines only -- the function carries a comment explaining why it does NOT stamp the
|
||||
# clock, and a check that flagged the comment would be worse than no check at all.
|
||||
accept_code = "\n".join(l for l in accept.splitlines()
|
||||
if not l.strip().startswith(("//", "*", "/*")))
|
||||
check("_lastInboundMs" not in accept_code,
|
||||
"acceptNew() does not refresh the controller-silence clock")
|
||||
|
||||
# And closing a read-only /status request must not refresh it either -- the first fix
|
||||
# stamped it on every close, which held a dead controller's armed state open exactly as
|
||||
# effectively as the original bug did.
|
||||
close_fn = server_src[server_src.index("void ControlServer::closeLink"):
|
||||
server_src.index("}", server_src.index("_onLinkChange(_ctx, false)"))]
|
||||
check("if (wasLinked) _lastInboundMs = millis();" in close_fn,
|
||||
"closeLink() stamps the silence clock only when a controller link is what closed")
|
||||
check("const bool wasLinked = _phase == Phase::WebSocket;" in close_fn,
|
||||
"closeLink() treats anything that reached the WebSocket phase as able to leave "
|
||||
"keys down, not only what is still marked authenticated")
|
||||
|
||||
print("the target-side verifier")
|
||||
# Twice now the verifier itself was the bug -- mislabelled LEDs, unnamed key codes that
|
||||
# looked like lost keystrokes -- so the thing that decides what "it arrived" means gets
|
||||
# tested too, and the mouse half of it especially: it is new, and a watcher that misread
|
||||
# REL_X would either cry wolf or miss a mouse that worked.
|
||||
import watch_target as wt # noqa: E402
|
||||
|
||||
# EV_REL is what makes a node a pointing device. Nothing here trusts a device name.
|
||||
check_eq(wt.classify(1 << wt.EV_REL | 1 << wt.EV_KEY), "mouse", "EV_REL means mouse")
|
||||
check_eq(wt.classify(1 << wt.EV_KEY | 1 << wt.EV_LED), "keyboard",
|
||||
"EV_KEY without EV_REL means keyboard")
|
||||
check_eq(wt.classify(1 << wt.EV_ABS), None,
|
||||
"an absolute-only device is neither, and is not watched as a mouse")
|
||||
check_eq(wt.capability_bits("/nonexistent/does/not/exist"), 0,
|
||||
"an unreadable sysfs file is 0 rather than an exception")
|
||||
|
||||
m = wt.MouseWatch(False)
|
||||
check(m.event(0, 0, 20) is None and m.event(0, 1, -6) is None,
|
||||
"a raw axis event is not a line -- one report is one line")
|
||||
check(m.event(0, 0, 15) is None, "and more movement in the same report accumulates")
|
||||
line = m.flush(0.1)
|
||||
check(line is not None and "+35" in line and "-6" in line,
|
||||
f"the report comes out as the sum of its axes ({line!r})")
|
||||
check(m.flush(0.2) is None, "a report with no movement prints nothing")
|
||||
check_eq(m.x, 35, "net X is the exact sum of what the target received")
|
||||
check_eq(m.y, -6, "and so is net Y")
|
||||
check_eq(m.travel, 41, "and the path length counts both, not the diagonal")
|
||||
|
||||
# REL_WHEEL_HI_RES is 1/240 of a notch. Counting it as a notch would report 240x the
|
||||
# scroll that happened, which is the same class of error as the mislabelled LEDs.
|
||||
# The axis numbers, pinned to the kernel's, not to the HID report's byte order. Getting
|
||||
# this wrong made a working wheel look dead -- see the comment on REL_NAMES.
|
||||
check_eq((wt.REL_X, wt.REL_Y, wt.REL_WHEEL, wt.REL_HWHEEL), (0, 1, 8, 6),
|
||||
"axis codes are the kernel's: REL_WHEEL is 8, not 2")
|
||||
check_eq((wt.REL_WHEEL_HI_RES, wt.REL_HWHEEL_HI_RES), (11, 12),
|
||||
"and the hi-res wheel codes are 11/12, not 6/7")
|
||||
|
||||
m2 = wt.MouseWatch(False)
|
||||
check(m2.event(0, wt.REL_WHEEL, -3) is not None,
|
||||
"a wheel notch is printed as it happens, not coalesced with movement")
|
||||
m2.event(0, wt.REL_WHEEL_HI_RES, 720) # the same notches again, finer
|
||||
check_eq(m2.wheel, -3, "hi-res wheel events are not counted as extra notches")
|
||||
check("REL_WHEEL" in "\n".join(m2.report()),
|
||||
"and the summary names the axis it saw, so a wheel that arrived is visible")
|
||||
m3 = wt.MouseWatch(False)
|
||||
m3.event(0, 40, 5) # an axis this tool does not know
|
||||
check(any("rel40" in l for l in m3.report()),
|
||||
"an unnamed axis code is counted and shown, never dropped in silence")
|
||||
m2.event(0, 272, 1)
|
||||
check("still held" in "\n".join(m2.report()),
|
||||
"a button the target still believes is down is called out, in those words")
|
||||
m2.event(0, 272, 0)
|
||||
check("still held" not in "\n".join(m2.report()),
|
||||
"and the warning goes away when it is released")
|
||||
|
||||
k = wt.KeyboardWatch(False)
|
||||
k.event(0, 30, 1)
|
||||
k.event(0, 30, 0)
|
||||
check("a" in "".join(k.typed), "the keyboard watcher still reassembles typed text")
|
||||
kc = wt.KeyboardWatch(False)
|
||||
for code in (29, 56, 111):
|
||||
kc.event(0, code, 1)
|
||||
check(any("Ctrl+Alt+Del" in l for l in kc.report()),
|
||||
"and still names Ctrl+Alt+Del as one combination, not three keys")
|
||||
|
||||
print(f"relayctl {checks} checks, {failures} failures")
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
Reference in New Issue
Block a user