Files
keyrelay/tests/test_text.cpp
T
ducoterra 9bc8d5213c Initial import: keyrelay, a WiFi-controlled USB HID keyboard
Browser or terminal controller drives an ESP32-S3 that acts as a Logitech
G815 into a target machine running no software. Disarmed by default; arming
requires the passphrase, the board button, or the serial console, and every
fail-safe path cancels in-flight work.

- src/: firmware with pure, host-testable modules (keystate, proto, text,
  ws_codec, sha1), the WebSocket control server, and the served page
- tests/: host suites for the pure modules, relayctl parity, and page JS
- tools/: relayctl terminal controller, serial console, bootloader helper,
  and an evdev target watcher
- platformio.ini: bridge (USB HID) and console (no-HID) environments
- README.md: single source of truth for behaviour, protocol and safety

LLM-Generated: true
2026-11-08 00:20:33 -05:00

387 lines
14 KiB
C++

// tests for pasted text: tests/test_text.cpp
//
// The character map is the part worth being pedantic about: every one of the 95
// printable ASCII characters has to map to a real keystroke, because a paste that
// silently loses or changes one character is worse than a paste that refuses. So
// the whole range is walked, not sampled.
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "keystate.h"
#include "proto.h"
#include "test.h"
#include "text.h"
using kr::KeyboardReport;
using kr::KeyboardState;
using kr::text::Result;
using kr::text::Stroke;
using kr::text::Typewriter;
namespace {
int countHeld(const KeyboardReport &r) {
int n = 0;
for (size_t i = 0; i < kr::kReportSlots; i++) {
if (r.keys[i] != kr::kUsageNone) n++;
}
return n;
}
} // namespace
int main() {
CASE("every printable ASCII character maps to a keystroke");
{
for (int ch = 0x20; ch <= 0x7E; ch++) {
const Stroke s = kr::text::strokeFor(static_cast<uint8_t>(ch));
CHECK(s.typable);
CHECK(s.usage != kr::kUsageNone); // typable implies a real usage id
}
}
CASE("shifted glyphs ask for LShift, unshifted ones do not");
{
// Letters: position, plus Shift for the uppercase -- never a different key.
const Stroke a = kr::text::strokeFor('a'), A = kr::text::strokeFor('A');
CHECK(a.typable && A.typable);
CHECK_EQ(a.usage, 0x04);
CHECK_EQ(A.usage, 0x04); // same physical key
CHECK_EQ(a.mods, 0);
CHECK_EQ(A.mods, kr::kModLshift);
CHECK_EQ(kr::text::strokeFor('z').usage, 0x1D);
CHECK_EQ(kr::text::strokeFor('Z').usage, 0x1D);
CHECK_EQ(kr::text::strokeFor('M').usage, 0x10);
// Digits: 1..9 run 0x1E..0x26 and 0 is the one that breaks the arithmetic.
CHECK_EQ(kr::text::strokeFor('1').usage, 0x1E);
CHECK_EQ(kr::text::strokeFor('9').usage, 0x26);
CHECK_EQ(kr::text::strokeFor('0').usage, 0x27);
CHECK_EQ(kr::text::strokeFor('0').mods, 0);
// The shifted number row, which is where a shifted-vs-unshifted slip shows up.
const int shiftPairs[][2] = {
{'!', 0x1E}, {'@', 0x1F}, {'#', 0x20}, {'$', 0x21}, {'%', 0x22},
{'^', 0x23}, {'&', 0x24}, {'*', 0x25}, {'(', 0x26}, {')', 0x27},
{'_', 0x2D}, {'+', 0x2E}, {'{', 0x2F}, {'}', 0x30}, {'|', 0x31},
{':', 0x33}, {'"', 0x34}, {'~', 0x35}, {'<', 0x36}, {'>', 0x37},
{'?', 0x38},
};
for (const auto &p : shiftPairs) {
const Stroke s = kr::text::strokeFor(static_cast<uint8_t>(p[0]));
CHECK(s.typable);
CHECK_EQ(s.usage, p[1]);
CHECK_EQ(s.mods, kr::kModLshift);
}
// And their unshifted partners on the same keys.
CHECK_EQ(kr::text::strokeFor('-').usage, 0x2D);
CHECK_EQ(kr::text::strokeFor('-').mods, 0);
CHECK_EQ(kr::text::strokeFor('=').usage, 0x2E);
CHECK_EQ(kr::text::strokeFor('[').usage, 0x2F);
CHECK_EQ(kr::text::strokeFor(']').usage, 0x30);
CHECK_EQ(kr::text::strokeFor('\\').usage, 0x31);
CHECK_EQ(kr::text::strokeFor(';').usage, 0x33);
CHECK_EQ(kr::text::strokeFor('\'').usage, 0x34);
CHECK_EQ(kr::text::strokeFor('`').usage, 0x35);
CHECK_EQ(kr::text::strokeFor(',').usage, 0x36);
CHECK_EQ(kr::text::strokeFor('.').usage, 0x37);
CHECK_EQ(kr::text::strokeFor('/').usage, 0x38);
}
CASE("whitespace");
{
CHECK_EQ(kr::text::strokeFor(' ').usage, kr::kUsageSpace);
CHECK_EQ(kr::text::strokeFor('\t').usage, kr::kUsageTab);
CHECK_EQ(kr::text::strokeFor('\n').usage, kr::kUsageEnter);
// \r types Enter. Normalising CRLF to LF is the controller's job: doing it here
// would need a look-ahead, and a chunk boundary can split the pair.
CHECK_EQ(kr::text::strokeFor('\r').usage, kr::kUsageEnter);
}
CASE("characters this map cannot express are refused, not guessed at");
{
const int refused[] = {0x00, 0x01, 0x07, 0x08, 0x0B, 0x0C, 0x1B, 0x1F, 0x7F,
0x80, 0xA0, 0xC3, 0xE9, 0xFF}; // e.g. UTF-8 'é'
for (int ch : refused) {
const Stroke s = kr::text::strokeFor(static_cast<uint8_t>(ch));
CHECK(!s.typable);
CHECK_EQ(s.usage, 0);
CHECK_EQ(s.mods, 0);
}
}
CASE("feed: all of a frame or none of it");
{
Typewriter t;
const uint8_t ok[] = {'h', 'i'};
CHECK(kr::text::strokeFor('h').typable);
CHECK(t.feed(ok, 2).result == Result::Ok);
CHECK_EQ(t.pending(), 2);
const uint8_t mixed[] = {'o', '!', 0xC3, 'x'};
const auto bad = t.feed(mixed, 4);
CHECK(bad.result == Result::Untypeable);
CHECK_EQ(bad.index, 2); // offset within THIS frame
CHECK_EQ(bad.byte, 0xC3);
CHECK_EQ(t.pending(), 2); // nothing partial was appended
// The cap is on characters still to type, and a full buffer is reported as such
// rather than truncating a paste.
Typewriter big;
uint8_t run[kr::kTextJobCap];
memset(run, 'a', sizeof(run));
CHECK(big.feed(run, kr::kTextJobCap).result == Result::Ok);
CHECK(big.feed(run, 1).result == Result::Full);
CHECK_EQ(big.pending(), kr::kTextJobCap);
CHECK(Typewriter().feed(nullptr, 0).result == Result::Ok);
}
CASE("pace: 0 means the board's default, and a fast pace still separates edges");
{
Typewriter t;
const uint8_t one[] = {'q'};
t.feed(one, 1);
t.start(1000, 0);
CHECK_EQ(t.periodMs(), kr::kTextPeriodMsDefault);
CHECK(t.holdMs() >= 1);
CHECK(t.holdMs() <= kr::kTextHoldMaxMs);
CHECK_EQ(t.holdMs() + t.gapMs(), t.periodMs()); // one press and one release per char
t.cancel();
t.feed(one, 1);
t.start(0, 9999); // absurdly slow is clamped, not obeyed
CHECK_EQ(t.periodMs(), kr::kTextPeriodMaxMs);
CHECK_EQ(t.holdMs(), kr::kTextHoldMaxMs);
t.cancel();
t.feed(one, 1);
t.start(0, 1); // as fast as it will go
CHECK_EQ(t.holdMs(), 1);
CHECK_EQ(t.gapMs(), 0);
}
CASE("step: the whole report sequence for two characters");
{
Typewriter t;
const uint8_t txt[] = {'h', 'i'};
t.feed(txt, 2);
t.start(1000, 10); // hold 5, gap 5
CHECK_EQ(t.holdMs(), 5);
CHECK_EQ(t.gapMs(), 5);
KeyboardState baseline;
KeyboardReport r;
bool pressed = false;
CHECK(!t.step(999, baseline, r, pressed)); // not due yet
CHECK(t.step(1000, baseline, r, pressed)); // press 'h'
CHECK(pressed);
CHECK_EQ(r.modifiers, 0);
CHECK_EQ(countHeld(r), 1);
CHECK_EQ(r.keys[0], kr::text::strokeFor('h').usage);
CHECK(!t.step(1004, baseline, r, pressed)); // still holding
CHECK(t.step(1005, baseline, r, pressed)); // release
CHECK(!pressed);
CHECK_EQ(countHeld(r), 0);
CHECK(t.step(1010, baseline, r, pressed)); // press 'i'
CHECK(pressed);
CHECK_EQ(r.keys[0], kr::text::strokeFor('i').usage);
CHECK(t.step(1015, baseline, r, pressed)); // release, and the run is over
CHECK(!t.active());
CHECK_EQ(t.pending(), 0);
CHECK(!t.step(2000, baseline, r, pressed)); // and nothing is left to emit
}
CASE("a paste is typed on top of what the operator holds, and back to it");
{
Typewriter t;
const uint8_t txt[] = {'A'}; // uppercase: this one needs Shift
t.feed(txt, 1);
t.start(0, 20);
// The operator is holding Ctrl. A pasted 'A' is therefore Ctrl+Shift+a.
KeyboardState baseline;
const uint8_t held[] = {kr::text::strokeFor('s').usage};
baseline.apply(kr::kModLctrl, held, 1);
KeyboardReport r;
bool pressed = false;
CHECK(t.step(0, baseline, r, pressed));
CHECK_EQ(r.modifiers, kr::kModLctrl | kr::kModLshift);
CHECK_EQ(countHeld(r), 2);
CHECK(baseline.isDown(kr::text::strokeFor('s').usage));
CHECK(t.step(10, baseline, r, pressed)); // release returns to the baseline
CHECK_EQ(r.modifiers, kr::kModLctrl); // Ctrl still down
CHECK_EQ(countHeld(r), 1);
CHECK_EQ(r.keys[0], kr::text::strokeFor('s').usage); // not released by the paste
}
CASE("more than six keys together is reported as a real keyboard reports it");
{
Typewriter t;
const uint8_t txt[] = {'x'};
t.feed(txt, 1);
t.start(0, 20);
KeyboardState six;
const uint8_t keys6[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09};
six.apply(0, keys6, 6);
KeyboardReport r;
bool pressed = false;
CHECK(t.step(0, six, r, pressed)); // a seventh key is impossible:
for (size_t i = 0; i < kr::kReportSlots; i++) CHECK_EQ(r.keys[i], kr::kUsageErrorRollover);
KeyboardState rolled;
const uint8_t keys7[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A};
rolled.apply(0, keys7, 7);
CHECK(rolled.rollover());
Typewriter t2;
t2.feed(txt, 1);
t2.start(0, 20);
CHECK(t2.step(0, rolled, r, pressed));
CHECK_EQ(r.keys[0], kr::kUsageErrorRollover); // rollover propagates, never hides
}
CASE("cancel drops the rest of the run");
{
Typewriter t;
const uint8_t txt[] = {'o', 'n', 'e', ' ', 't', 'w', 'o'};
t.feed(txt, sizeof(txt));
t.start(0, 10);
CHECK(t.active());
KeyboardState baseline;
KeyboardReport r;
bool pressed = false;
CHECK(t.step(0, baseline, r, pressed)); // one character pressed
t.cancel(); // the panic path
CHECK(!t.active());
CHECK_EQ(t.pending(), 0);
CHECK(!t.step(1000, baseline, r, pressed)); // nothing continues afterwards
CHECK_EQ(t.feed(txt, sizeof(txt)).result, Result::Ok);
CHECK_EQ(t.pending(), sizeof(txt)); // and it is reusable
}
CASE("a run already in progress keeps the pace it started with");
{
Typewriter t;
const uint8_t txt[] = {'a'};
t.feed(txt, 1);
t.start(0, 10);
CHECK_EQ(t.periodMs(), 10);
t.feed(txt, 1);
t.start(0, 200); // a later frame must not reprieve
CHECK_EQ(t.periodMs(), 10); // or race the text already visible
t.cancel();
t.feed(txt, 1);
t.start(0, 200);
CHECK_EQ(t.periodMs(), 200); // a fresh run does take the new pace
}
CASE("text renders to the wire format and parses back");
{
char buf[512];
const uint8_t txt[] = {'h', 'i'};
const size_t n = kr::proto::formatText(buf, sizeof(buf), 0, txt, 2);
CHECK_EQ(n, 8u); // "P=0:6869"
CHECK_STR(buf, "P=0:6869");
kr::proto::Message m;
CHECK(kr::proto::parse(buf, strlen(buf), m) == kr::proto::Cmd::Text);
CHECK_EQ(m.periodMs, 0);
CHECK_EQ(m.nPayload, 2);
CHECK_EQ(m.payload[0], 'h');
CHECK_EQ(m.payload[1], 'i');
// The pace travels too, in decimal, and the whole frame is still ASCII.
kr::proto::formatText(buf, sizeof(buf), 250, txt, 2);
CHECK_STR(buf, "P=250:6869");
CHECK(kr::proto::parse(buf, strlen(buf), m) == kr::proto::Cmd::Text);
CHECK_EQ(m.periodMs, 250);
// The largest legal frame must fit in one WebSocket message: 256 bytes, or the
// board rejects the frame before the parser ever sees it.
uint8_t max[kr::kTextFrameMax];
memset(max, 'a', sizeof(max));
const size_t mlen = kr::proto::formatText(buf, sizeof(buf), kr::kTextPeriodMaxMs,
max, kr::kTextFrameMax);
CHECK(mlen > 0);
CHECK(mlen <= kr::kWsMessageCap);
CHECK(kr::proto::parse(buf, mlen, m) == kr::proto::Cmd::Text);
CHECK_EQ(m.nPayload, kr::kTextFrameMax);
// A buffer that cannot hold the whole frame gets nothing, not a truncated paste.
char small[8] = {0};
CHECK_EQ(kr::proto::formatText(small, sizeof(small), 0, max, 20), 0u);
CHECK_STR(small, "");
CHECK_EQ(kr::proto::formatText(buf, sizeof(buf), 0, max, 0), 0u); // empty is not a paste
CHECK_EQ(kr::proto::formatText(buf, sizeof(buf), kr::kTextPeriodMaxMs + 1, txt, 2), 0u);
}
CASE("a frame the controller page sends becomes the reports the target sees");
{
// The browser builds "P=<ms>:<hex>"; the board parses it, expands it and emits
// press/release pairs. This is the whole path in one place, with no browser and no
// USB in it -- which is exactly why it can be asserted here rather than guessed at.
const char *text = "Hi there";
std::string frame = "P=12:";
for (const char *p = text; *p; p++) {
char hex[3];
snprintf(hex, sizeof(hex), "%02x", (unsigned char)*p);
frame += hex;
}
kr::proto::Message m;
CHECK(kr::proto::parse(frame.c_str(), frame.size(), m) == kr::proto::Cmd::Text);
CHECK_EQ(m.nPayload, strlen(text));
CHECK_EQ(m.periodMs, 12);
Typewriter t;
CHECK(t.feed(m.payload, m.nPayload).result == Result::Ok);
t.start(0, m.periodMs);
KeyboardState none; // nothing held by the operator
std::vector<KeyboardReport> out;
uint32_t clockMs = 0;
// Ten seconds of loop passes at 1 ms: long enough to type it all out.
for (int tick = 0; tick < 10000; tick++, clockMs += 1) {
KeyboardReport r;
bool pressed = false;
if (!t.step(clockMs, none, r, pressed)) continue;
out.push_back(r);
}
const size_t len = strlen(text);
CHECK_EQ(out.size(), len * 2); // one press and one release per character
CHECK(!t.active()); // and it finished on its own
for (size_t i = 0; i < len; i++) {
const Stroke want = kr::text::strokeFor((uint8_t)text[i]);
const KeyboardReport &press = out[i * 2];
const KeyboardReport &release = out[i * 2 + 1];
CHECK_EQ(press.modifiers, want.mods);
CHECK_EQ(press.keys[0], want.usage);
CHECK_EQ(countHeld(press), 1);
CHECK_EQ(release.modifiers, 0); // nothing left down between characters
CHECK_EQ(countHeld(release), 0);
}
// 'H' is the shifted glyph and 'i' is not: the two reports differ in Shift and
// nothing else. Usages are positions, so H is 0x0B and i is 0x0C.
CHECK_EQ(out[0].modifiers, kr::kModLshift);
CHECK_EQ(out[0].keys[0], 0x0b);
CHECK_EQ(out[2].modifiers, 0);
CHECK_EQ(out[2].keys[0], 0x0c);
// The space in "Hi there" is index 2, and a key -- not an absence of one.
CHECK_EQ(out[4].keys[0], kr::kUsageSpace);
CHECK_EQ(out[6].keys[0], 0x17); // 't'
}
return report("text");
}