// 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 #include #include #include #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(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(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(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=:"; 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 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"); }