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
540 lines
22 KiB
C++
540 lines
22 KiB
C++
// tests for the control protocol: tests/test_proto.cpp
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
|
|
#include "keystate.h"
|
|
#include "proto.h"
|
|
#include "test.h"
|
|
|
|
using kr::proto::Cmd;
|
|
using kr::proto::Message;
|
|
|
|
namespace {
|
|
|
|
Cmd parse(const char *s) {
|
|
static Message m;
|
|
kr::proto::initMessage(m);
|
|
const Cmd c = kr::proto::parse(s, strlen(s), m);
|
|
return c;
|
|
}
|
|
|
|
Cmd parse(const char *s, Message &m) { return kr::proto::parse(s, strlen(s), m); }
|
|
|
|
std::string readFile(const char *path) {
|
|
std::string out;
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f) return out;
|
|
char buf[4096];
|
|
size_t n;
|
|
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n);
|
|
fclose(f);
|
|
return out;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
CASE("single-character commands");
|
|
{
|
|
CHECK(parse("H") == Cmd::Hello);
|
|
CHECK(parse("K") == Cmd::KeepAlive);
|
|
CHECK(parse("R") == Cmd::ReleaseAll);
|
|
CHECK(parse("C") == Cmd::SecureAttention);
|
|
CHECK(parse("A1") == Cmd::Arm);
|
|
CHECK(parse("A0") == Cmd::Disarm);
|
|
// hand-typed clients end lines with \n: tolerated
|
|
CHECK(parse("A1\n") == Cmd::Arm);
|
|
CHECK(parse("R\r\n") == Cmd::ReleaseAll);
|
|
}
|
|
|
|
CASE("garbage is rejected, never half-accepted");
|
|
{
|
|
CHECK(parse("") == Cmd::Bad);
|
|
CHECK(parse("A") == Cmd::Bad); // arm/disarm needs its digit
|
|
CHECK(parse("A2") == Cmd::Bad);
|
|
CHECK(parse("A11") == Cmd::Bad);
|
|
CHECK(parse("HH") == Cmd::Bad);
|
|
CHECK(parse("Z") == Cmd::Bad);
|
|
CHECK(parse("hello") == Cmd::Bad);
|
|
CHECK(parse("S") == Cmd::Bad);
|
|
CHECK(parse("S=") == Cmd::Bad);
|
|
}
|
|
|
|
CASE("state message: modifiers and usages");
|
|
{
|
|
Message m;
|
|
CHECK(parse("S=00:", m) == Cmd::State);
|
|
CHECK_EQ(m.nUsages, 0); // "nothing pressed" is a legal state
|
|
CHECK_EQ(m.mods, 0);
|
|
|
|
CHECK(parse("S=04:28", m) == Cmd::State); // LeftAlt + Enter
|
|
CHECK_EQ(m.mods, 0x04);
|
|
CHECK_EQ(m.nUsages, 1);
|
|
CHECK_EQ(m.usages[0], 0x28);
|
|
|
|
CHECK(parse("S=0a:04,05,06", m) == Cmd::State);
|
|
CHECK_EQ(m.mods, 0x0A);
|
|
CHECK_EQ(m.nUsages, 3);
|
|
CHECK_EQ(m.usages[2], 0x06);
|
|
|
|
CHECK(parse("S=0A:AB,cd", m) == Cmd::State); // hex case is free
|
|
CHECK_EQ(m.mods, 0x0A);
|
|
CHECK_EQ(m.usages[0], 0xAB);
|
|
CHECK_EQ(m.usages[1], 0xCD);
|
|
}
|
|
|
|
CASE("state message: strict field syntax");
|
|
{
|
|
CHECK(parse("S=00:04,") == Cmd::Bad); // trailing comma
|
|
CHECK(parse("S=00:4") == Cmd::Bad); // usage must be two hex digits
|
|
CHECK(parse("S=00:04gg") == Cmd::Bad); // not hex
|
|
CHECK(parse("S=0:0400") == Cmd::Bad); // modifier byte must be two digits
|
|
CHECK(parse("S=00:00") == Cmd::Bad); // 0x00 is not a key
|
|
CHECK(parse("S=00 :04") == Cmd::Bad); // no spaces anywhere
|
|
CHECK(parse("S=00") == Cmd::Bad); // the colon is not optional
|
|
CHECK(parse("S =00:") == Cmd::Bad);
|
|
}
|
|
|
|
CASE("state message: bounds");
|
|
{
|
|
// exactly the cap is fine, one over is not
|
|
char big[128];
|
|
for (int n = 1; n <= static_cast<int>(kr::kMaxKeysPerMessage) + 2; n++) {
|
|
std::strcpy(big, "S=00:");
|
|
size_t o = std::strlen(big);
|
|
for (int i = 0; i < n; i++) {
|
|
big[o++] = '0';
|
|
big[o++] = static_cast<char>('4' + (i % 6));
|
|
big[o++] = ',';
|
|
}
|
|
big[o - 1] = '\0';
|
|
const bool withinCap = n <= static_cast<int>(kr::kMaxKeysPerMessage);
|
|
CHECK(parse(big) == (withinCap ? Cmd::State : Cmd::Bad));
|
|
}
|
|
}
|
|
|
|
CASE("chord message must press something");
|
|
{
|
|
Message m;
|
|
CHECK(parse("Y=05:4c", m) == Cmd::Chord); // Ctrl+Alt+Del as a chord
|
|
CHECK_EQ(m.mods, 0x05);
|
|
CHECK_EQ(m.usages[0], 0x4C);
|
|
|
|
// A lone Super/Win press is a chord of nothing but a modifier ...
|
|
CHECK(parse("Y=08:", m) == Cmd::Chord);
|
|
CHECK_EQ(m.mods, 0x08);
|
|
CHECK_EQ(m.nUsages, 0);
|
|
// ... but a chord that presses neither a modifier nor a key is not a chord.
|
|
CHECK(parse("Y=00:") == Cmd::Bad);
|
|
CHECK(parse("Y=05") == Cmd::Bad); // the colon is not optional
|
|
}
|
|
|
|
CASE("auth message: token charset and bounds");
|
|
{
|
|
Message m;
|
|
CHECK(parse("Thunter2", m) == Cmd::Auth);
|
|
CHECK_STR(m.text, "hunter2");
|
|
CHECK(parse("Tabc._-:9", m) == Cmd::Auth);
|
|
CHECK_STR(m.text, "abc._-:9");
|
|
CHECK(parse("T") == Cmd::Bad); // empty token
|
|
CHECK(parse("Tbad token") == Cmd::Bad); // spaces and control chars are out
|
|
CHECK(parse("Tbad\tx") == Cmd::Bad);
|
|
char over[80];
|
|
std::memset(over, 'a', sizeof(over));
|
|
over[0] = 'T';
|
|
over[kr::kTokenMax + 2] = '\0'; // "T" + 33 chars = one too long
|
|
CHECK_EQ(std::strlen(over), kr::kTokenMax + 2u); // "T" + 33 token chars
|
|
CHECK(parse(over) == Cmd::Bad);
|
|
char fits[80];
|
|
std::memset(fits, 'a', sizeof(fits));
|
|
fits[0] = 'T';
|
|
fits[kr::kTokenMax + 1] = '\0'; // token exactly at the limit
|
|
CHECK(parse(fits) == Cmd::Auth);
|
|
}
|
|
|
|
CASE("state renders back to the same wire format");
|
|
{
|
|
char buf[64];
|
|
const uint8_t keys[] = {0x04, 0x28};
|
|
CHECK_EQ(kr::proto::formatState(buf, sizeof(buf), 0x0A, keys, 2), 10u);
|
|
CHECK_STR(buf, "S=0a:04,28");
|
|
|
|
// "nothing pressed" needs the colon, and needs a buffer that fits the NUL
|
|
CHECK_EQ(kr::proto::formatState(buf, 5, 0, nullptr, 0), 0u);
|
|
CHECK_EQ(kr::proto::formatState(buf, 6, 0, nullptr, 0), 5u);
|
|
CHECK_STR(buf, "S=00:");
|
|
|
|
CHECK(kr::proto::formatState(buf, sizeof(buf), 0, nullptr, 0) > 0);
|
|
CHECK_STR(buf, "S=00:");
|
|
|
|
CHECK(kr::proto::formatChord(buf, sizeof(buf), 0x05, keys, 1) > 0);
|
|
CHECK_STR(buf, "Y=05:04");
|
|
|
|
// a too-small buffer reports failure instead of writing past the end
|
|
char tiny[6] = {0};
|
|
CHECK_EQ(kr::proto::formatState(tiny, sizeof(tiny), 0x0A, keys, 2), 0u);
|
|
}
|
|
|
|
CASE("round trip: render then parse yields the same state");
|
|
{
|
|
char buf[64];
|
|
const uint8_t keys[] = {0x04, 0x05, 0x28, 0x4C};
|
|
kr::proto::formatState(buf, sizeof(buf), 0x5A, keys, 4);
|
|
Message m;
|
|
CHECK(parse(buf, m) == Cmd::State);
|
|
CHECK_EQ(m.mods, 0x5A);
|
|
CHECK_EQ(m.nUsages, 4);
|
|
for (int i = 0; i < 4; i++) CHECK_EQ(m.usages[i], keys[i]);
|
|
}
|
|
|
|
CASE("error frames carry their code");
|
|
{
|
|
char buf[32];
|
|
CHECK(kr::proto::formatError(buf, sizeof(buf), 4, "arm required") > 0);
|
|
CHECK_STR(buf, "E4:arm required");
|
|
CHECK_EQ(kr::proto::formatError(buf, 4, 4, "too long for this buffer"), 0u);
|
|
}
|
|
|
|
CASE("text message: pace, hex, and the bounds that keep one frame one command");
|
|
{
|
|
Message m;
|
|
CHECK(parse("P=0:6869", m) == Cmd::Text);
|
|
CHECK_EQ(m.periodMs, 0); // 0 = the board's own default pace
|
|
CHECK_EQ(m.nPayload, 2);
|
|
CHECK_EQ(m.payload[0], 'h');
|
|
CHECK_EQ(m.payload[1], 'i');
|
|
|
|
CHECK(parse("P=250:68", m) == Cmd::Text);
|
|
CHECK_EQ(m.periodMs, 250);
|
|
CHECK(parse("P=1:68", m) == Cmd::Text);
|
|
CHECK_EQ(m.periodMs, 1);
|
|
|
|
// Strict, like every other command: the digits, the colon and a whole number
|
|
// of bytes are all required, and a rejected frame types nothing at all.
|
|
CHECK(parse("P") == Cmd::Bad);
|
|
CHECK(parse("P=") == Cmd::Bad);
|
|
CHECK(parse("P=:68") == Cmd::Bad); // the pace is not optional
|
|
CHECK(parse("P=12") == Cmd::Bad); // no colon, no text
|
|
CHECK(parse("P=12:") == Cmd::Bad); // empty text is not a paste
|
|
CHECK(parse("P=12:6") == Cmd::Bad); // half a byte
|
|
CHECK(parse("P=12:686") == Cmd::Bad);
|
|
CHECK(parse("P=12:zz") == Cmd::Bad);
|
|
CHECK(parse("P=12:68 69") == Cmd::Bad); // no spaces
|
|
CHECK(parse("P=251:68") == Cmd::Bad); // slower than anything sane
|
|
CHECK(parse("P=99999:68") == Cmd::Bad); // and no overflow on the digits
|
|
CHECK(parse("P=12:6869extra") == Cmd::Bad);
|
|
CHECK(parse("PX:68") == Cmd::Bad);
|
|
|
|
// One frame is one command: kTextFrameMax characters must fit inside one 256-byte
|
|
// frame, and one more must be refused rather than buffered or truncated.
|
|
std::string body(2 * kr::kTextFrameMax, '0');
|
|
for (size_t i = 0; i < body.size(); i++) body[i] = (i & 1) ? '1' : '6'; // all 'a'
|
|
const std::string big = "P=12:" + body;
|
|
const std::string more = big + "61";
|
|
CHECK_EQ(big.size(), 5u + 2u * kr::kTextFrameMax);
|
|
CHECK(big.size() <= kr::kWsMessageCap);
|
|
CHECK(kr::proto::parse(big.c_str(), big.size(), m) == Cmd::Text);
|
|
CHECK_EQ(m.nPayload, kr::kTextFrameMax);
|
|
CHECK_EQ(m.payload[0], 'a');
|
|
CHECK(more.size() <= kr::kWsMessageCap); // refused on count, not on length
|
|
CHECK(kr::proto::parse(more.c_str(), more.size(), m) == Cmd::Bad);
|
|
|
|
// A trailing newline is tolerated, as it is for every other command.
|
|
CHECK(parse("P=0:6869\n") == Cmd::Text);
|
|
}
|
|
|
|
CASE("constant-time token comparison");
|
|
{
|
|
CHECK(kr::proto::constTimeEq("hunter2", 7, "hunter2", 7));
|
|
CHECK(!kr::proto::constTimeEq("hunter2", 7, "hunter3", 7));
|
|
CHECK(!kr::proto::constTimeEq("hunter2", 7, "hunter", 6));
|
|
CHECK(!kr::proto::constTimeEq("", 0, "x", 1));
|
|
CHECK(kr::proto::constTimeEq("", 0, "", 0));
|
|
CHECK(!kr::proto::constTimeEq("hunter22", 8, "hunter2", 7)); // prefix is not a match
|
|
}
|
|
|
|
CASE("every button on the controller page is a chord the firmware accepts");
|
|
{
|
|
// The page's special keys carry their chord in the wire format itself, as
|
|
// data-chord="<mods>:<usages>". That is what keeps the page free of chord logic --
|
|
// and what makes a typo in one of them a button that either does nothing or, worse,
|
|
// sends a different key than the label on it promises. Nothing on the browser side
|
|
// would ever notice, so every one goes through the real parser here.
|
|
std::string html = readFile("src/web_page.h");
|
|
if (html.empty()) html = readFile("../src/web_page.h");
|
|
CHECK(!html.empty());
|
|
|
|
int buttons = 0, accepted = 0;
|
|
const std::string needle = "data-chord=\"";
|
|
size_t pos = 0;
|
|
while ((pos = html.find(needle, pos)) != std::string::npos) {
|
|
const size_t begin = pos + needle.size();
|
|
const size_t end = html.find('"', begin);
|
|
if (end == std::string::npos) break;
|
|
const std::string wire = "Y=" + html.substr(begin, end - begin);
|
|
Message m;
|
|
kr::proto::initMessage(m);
|
|
if (kr::proto::parse(wire.c_str(), wire.size(), m) == Cmd::Chord &&
|
|
(m.mods != 0 || m.nUsages > 0)) {
|
|
accepted++;
|
|
}
|
|
buttons++;
|
|
pos = end;
|
|
}
|
|
CHECK(buttons >= 8); // a broken extraction must not read as a pass
|
|
CHECK_EQ(buttons, accepted);
|
|
|
|
// The window-management keys the README documents. Asserted by their bytes so a
|
|
// silent rename cannot leave the documentation describing a button that is gone.
|
|
const std::string gui = std::string("08:");
|
|
CHECK_EQ((int)kr::kModLgui, 0x08); // the page's "08" must mean what this says
|
|
CHECK(html.find("data-chord=\"" + gui + "50\"") != std::string::npos); // Super+Left
|
|
CHECK(html.find("data-chord=\"" + gui + "4f\"") != std::string::npos); // Super+Right
|
|
CHECK(html.find("data-chord=\"" + gui + "4b\"") != std::string::npos); // Super+PageUp
|
|
}
|
|
|
|
CASE("every chord button sends the combination printed on it");
|
|
{
|
|
// The test above proves each chord PARSES, which is not the same claim: an
|
|
// Alt+Tab button whose bytes were Ctrl+Alt+A parses perfectly, is armed-gated,
|
|
// arrives intact, and does something else entirely. Nothing in the browser would
|
|
// ever notice. So each labelled button is pinned to the combination its label
|
|
// promises, by modifier byte and usage id.
|
|
struct Expected { const char *label; const char *wire; uint8_t mods; uint8_t usage; };
|
|
const Expected expected[] = {
|
|
{"Ctrl+Alt+F1", "05:3a", kr::kModLctrl | kr::kModLalt, 0x3a},
|
|
{"Ctrl+Alt+F2", "05:3b", kr::kModLctrl | kr::kModLalt, 0x3b},
|
|
{"Ctrl+Alt+F3", "05:3c", kr::kModLctrl | kr::kModLalt, 0x3c},
|
|
{"Ctrl+Alt+F4", "05:3d", kr::kModLctrl | kr::kModLalt, 0x3d},
|
|
{"Alt+Tab", "04:2b", kr::kModLalt, kr::kUsageTab},
|
|
{"Win", "08:", kr::kModLgui, 0x00},
|
|
{"PrtSc", "00:46", 0x00, 0x46},
|
|
{"Menu", "00:65", 0x00, 0x65},
|
|
{"Super+Left", "08:50", kr::kModLgui, 0x50},
|
|
{"Super+Right", "08:4f", kr::kModLgui, 0x4f},
|
|
{"Super+PageUp", "08:4b", kr::kModLgui, 0x4b},
|
|
};
|
|
|
|
std::string html = readFile("src/web_page.h");
|
|
if (html.empty()) html = readFile("../src/web_page.h");
|
|
CHECK(!html.empty());
|
|
|
|
for (const auto &e : expected) {
|
|
const std::string attr = std::string("data-chord=\"") + e.wire + "\"";
|
|
CHECK(html.find(attr) != std::string::npos);
|
|
|
|
const std::string wire = std::string("Y=") + e.wire;
|
|
Message m;
|
|
CHECK(kr::proto::parse(wire.c_str(), wire.size(), m) == Cmd::Chord);
|
|
if (m.mods != e.mods || m.usages[0] != e.usage ||
|
|
(e.usage != 0 && m.nUsages != 1)) {
|
|
std::printf(" %s is on the wire as %s, which is not what it says\n",
|
|
e.label, wire.c_str());
|
|
}
|
|
CHECK_EQ(m.mods, e.mods);
|
|
CHECK_EQ(e.usage == 0 ? 0 : m.usages[0], e.usage);
|
|
CHECK(e.usage == 0 ? m.nUsages == 0 : m.nUsages == 1);
|
|
}
|
|
|
|
// Alt+Tab specifically, because it was wrong once: LAlt alone with Tab, and no
|
|
// Ctrl anywhere near it -- Ctrl+Alt+something is a VT switch or a panic key.
|
|
CHECK(kr::kModLctrl == 0x01 && kr::kModLalt == 0x04);
|
|
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,
|
|
// using numbers it has to keep in step with config.h by hand. If they drift, the
|
|
// board answers E8 in the middle of somebody's paste.
|
|
std::string html = readFile("src/web_page.h");
|
|
if (html.empty()) html = readFile("../src/web_page.h");
|
|
CHECK(!html.empty());
|
|
|
|
char needle[64];
|
|
snprintf(needle, sizeof(needle), "const TEXT_FRAME_MAX = %u;",
|
|
(unsigned)kr::kTextFrameMax);
|
|
CHECK(html.find(needle) != std::string::npos);
|
|
snprintf(needle, sizeof(needle), "const TEXT_JOB_MAX = %u;", (unsigned)kr::kTextJobCap);
|
|
CHECK(html.find(needle) != std::string::npos);
|
|
|
|
// And the page must actually use the P= command the firmware parses.
|
|
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");
|
|
}
|