Public Access
The SSH App opens one session to a shell, over libssh (LibSSH-ESP32 5.10.0) on mbedTLS. A server is trusted the first time on its fingerprint, and a changed key is a warning with Cancel selected. The password is typed each time and kept nowhere; or the device makes itself an Ed25519 key, whose public half is shown, written to /ssh/id_ed25519.pub and printed by `ssh status`. lib/term is the terminal: what a shell, less, top, nano and vim send, with sixteen colours, scroll regions, the alternate screen and 100 lines of scrollback. Five text sizes with Ctrl and + or -, from 60x20 to 26x8, told to the far end. The session goes on when the App is left; SSH shows in the Status Bar. `ssh user@host` in the Shell opens the App. Also: - Keys that aren't characters carry Shift, Ctrl and Alt. The terminal needs it, and it makes Ctrl+Fn+up/down in a note and Shift+Tab in Gemini work from the real keyboard. - IRC doesn't try to connect under 60 KB free: started with a session open, its TLS handshake took the heap down to 236 bytes. - libssh's own curve25519 is left out of the build (scripts/libssh_filter.py): libsodium's has the same names. Costs 292 KB of flash and about 50 KB of heap while a session is open; not started under 75 KB free. Docs: guide page, how-to, FAQ, home page, Status Bar, SD card folders, the memory how-to, README, glossary, N1 notes with Q254 to Q266 and the checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
332 lines
10 KiB
C++
332 lines
10 KiB
C++
#include <unity.h>
|
|
|
|
#include <vector>
|
|
|
|
#include "key_mapper.h"
|
|
|
|
using namespace roro;
|
|
|
|
void setUp() {}
|
|
void tearDown() {}
|
|
|
|
static RawKeys chars(std::vector<char> c) {
|
|
RawKeys r;
|
|
r.chars = c;
|
|
return r;
|
|
}
|
|
|
|
static RawKeys withFn(std::vector<char> c) {
|
|
RawKeys r = chars(c);
|
|
r.fn = true;
|
|
return r;
|
|
}
|
|
|
|
static RawKeys optOnly() {
|
|
RawKeys r;
|
|
r.opt = true;
|
|
return r;
|
|
}
|
|
|
|
static std::vector<KeyEvent> press(KeyMapper& m, const RawKeys& r) { return m.update(r); }
|
|
|
|
static void release(KeyMapper& m) { m.update(RawKeys{}); }
|
|
|
|
void test_plain_character() {
|
|
KeyMapper m;
|
|
auto ev = press(m, chars({'a'}));
|
|
TEST_ASSERT_EQUAL(1, ev.size());
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
|
|
TEST_ASSERT_EQUAL('a', ev[0].ch);
|
|
}
|
|
|
|
void test_held_key_is_not_repeated_on_later_changes() {
|
|
KeyMapper m;
|
|
press(m, chars({'a'}));
|
|
auto ev = press(m, chars({'a', 'b'})); // rolling: b pressed while a still held
|
|
TEST_ASSERT_EQUAL(1, ev.size());
|
|
TEST_ASSERT_EQUAL('b', ev[0].ch);
|
|
}
|
|
|
|
void test_same_key_again_after_release() {
|
|
KeyMapper m;
|
|
press(m, chars({'a'}));
|
|
release(m);
|
|
TEST_ASSERT_EQUAL(1, press(m, chars({'a'})).size());
|
|
}
|
|
|
|
void test_fn_combos_are_arrows() {
|
|
KeyMapper m;
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, withFn({';'}))[0].key));
|
|
release(m);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Down), static_cast<int>(press(m, withFn({'.'}))[0].key));
|
|
release(m);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Left), static_cast<int>(press(m, withFn({','}))[0].key));
|
|
release(m);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Right), static_cast<int>(press(m, withFn({'/'}))[0].key));
|
|
}
|
|
|
|
void test_backtick_is_back_and_fn_backtick_is_home() {
|
|
KeyMapper m;
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(press(m, chars({'`'}))[0].key));
|
|
release(m);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Home), static_cast<int>(press(m, withFn({'`'}))[0].key));
|
|
}
|
|
|
|
void test_enter_delete_tab() {
|
|
KeyMapper m;
|
|
RawKeys r;
|
|
r.enter = true;
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Select), static_cast<int>(press(m, r)[0].key));
|
|
release(m);
|
|
r = RawKeys{};
|
|
r.del = true;
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Delete), static_cast<int>(press(m, r)[0].key));
|
|
release(m);
|
|
r = RawKeys{};
|
|
r.tab = true;
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Tab), static_cast<int>(press(m, r)[0].key));
|
|
}
|
|
|
|
void test_ctrl_and_alt_flags_are_carried() {
|
|
KeyMapper m;
|
|
RawKeys r = chars({'c'});
|
|
r.ctrl = true;
|
|
auto ev = press(m, r);
|
|
TEST_ASSERT_TRUE(ev[0].ctrl);
|
|
TEST_ASSERT_EQUAL('c', ev[0].ch);
|
|
}
|
|
|
|
void test_compose_acute() {
|
|
KeyMapper m;
|
|
TEST_ASSERT_EQUAL(0, press(m, optOnly()).size());
|
|
release(m);
|
|
TEST_ASSERT_EQUAL(0, press(m, chars({'\''})).size());
|
|
release(m);
|
|
auto ev = press(m, chars({'e'}));
|
|
TEST_ASSERT_EQUAL(1, ev.size());
|
|
TEST_ASSERT_EQUAL(0xE9, ev[0].ch); // é
|
|
}
|
|
|
|
void test_compose_while_holding_opt() {
|
|
KeyMapper m;
|
|
RawKeys r = chars({'`'});
|
|
r.opt = true;
|
|
TEST_ASSERT_EQUAL(0, press(m, r).size()); // grave accent, not Back
|
|
release(m);
|
|
auto ev = press(m, chars({'a'}));
|
|
TEST_ASSERT_EQUAL(0xE0, ev[0].ch); // à
|
|
}
|
|
|
|
void test_compose_table() {
|
|
struct Case {
|
|
char accent, base;
|
|
uint32_t expected;
|
|
} cases[] = {{'^', 'e', 0xEA}, {'"', 'e', 0xEB}, {',', 'c', 0xE7}, {',', 'C', 0xC7},
|
|
{'~', 'n', 0xF1}, {'\'', 'E', 0xC9}, {'`', 'u', 0xF9}, {'"', 'i', 0xEF}};
|
|
for (auto& c : cases) {
|
|
KeyMapper m;
|
|
press(m, optOnly());
|
|
release(m);
|
|
press(m, chars({c.accent}));
|
|
release(m);
|
|
auto ev = press(m, chars({c.base}));
|
|
TEST_ASSERT_EQUAL_HEX32(c.expected, ev[0].ch);
|
|
}
|
|
}
|
|
|
|
void test_unknown_combination_types_both_characters() {
|
|
KeyMapper m;
|
|
press(m, optOnly());
|
|
release(m);
|
|
press(m, chars({'\''}));
|
|
release(m);
|
|
auto ev = press(m, chars({'z'}));
|
|
TEST_ASSERT_EQUAL(2, ev.size());
|
|
TEST_ASSERT_EQUAL('\'', ev[0].ch);
|
|
TEST_ASSERT_EQUAL('z', ev[1].ch);
|
|
}
|
|
|
|
void test_opt_followed_by_a_non_accent_types_it_normally() {
|
|
KeyMapper m;
|
|
press(m, optOnly());
|
|
release(m);
|
|
auto ev = press(m, chars({'x'}));
|
|
TEST_ASSERT_EQUAL(1, ev.size());
|
|
TEST_ASSERT_EQUAL('x', ev[0].ch);
|
|
}
|
|
|
|
void test_pressing_opt_twice_cancels() {
|
|
KeyMapper m;
|
|
press(m, optOnly());
|
|
release(m);
|
|
press(m, optOnly());
|
|
release(m);
|
|
auto ev = press(m, chars({'`'}));
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(ev[0].key));
|
|
}
|
|
|
|
void test_compose_state_is_visible_for_the_ui() {
|
|
KeyMapper m;
|
|
TEST_ASSERT_EQUAL(0, m.pendingCompose());
|
|
press(m, optOnly());
|
|
TEST_ASSERT_EQUAL('*', m.pendingCompose()); // armed, no accent yet
|
|
release(m);
|
|
press(m, chars({'^'}));
|
|
TEST_ASSERT_EQUAL('^', m.pendingCompose());
|
|
}
|
|
|
|
void test_arrow_keys_work_without_fn_when_not_typing() {
|
|
KeyMapper m;
|
|
m.setTextEntry(false);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, chars({';'}))[0].key));
|
|
release(m);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Down), static_cast<int>(press(m, chars({'.'}))[0].key));
|
|
release(m);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Left), static_cast<int>(press(m, chars({','}))[0].key));
|
|
release(m);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Right), static_cast<int>(press(m, chars({'/'}))[0].key));
|
|
}
|
|
|
|
void test_arrow_keys_type_characters_during_text_entry() {
|
|
KeyMapper m;
|
|
m.setTextEntry(true);
|
|
auto ev = press(m, chars({';'}));
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
|
|
TEST_ASSERT_EQUAL(';', ev[0].ch);
|
|
}
|
|
|
|
void test_fn_arrows_work_in_both_modes() {
|
|
KeyMapper m;
|
|
m.setTextEntry(false);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, withFn({';'}))[0].key));
|
|
release(m);
|
|
m.setTextEntry(true);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, withFn({';'}))[0].key));
|
|
}
|
|
|
|
void test_other_characters_still_type_when_not_typing() {
|
|
KeyMapper m;
|
|
m.setTextEntry(false);
|
|
auto ev = press(m, chars({'w'}));
|
|
TEST_ASSERT_EQUAL('w', ev[0].ch);
|
|
}
|
|
|
|
void test_shifted_arrow_keys_type_their_symbols_when_not_typing() {
|
|
// Shift+; is ':' — the driver reports the shifted character, which isn't an arrow.
|
|
KeyMapper m;
|
|
m.setTextEntry(false);
|
|
RawKeys r = chars({':'});
|
|
r.shift = true;
|
|
TEST_ASSERT_EQUAL(':', press(m, r)[0].ch);
|
|
}
|
|
|
|
// Issue #69, Q196: Fn+h everywhere, ? when it wouldn't be typed.
|
|
void test_fn_h_is_help_in_both_modes() {
|
|
for (bool typing : {true, false}) {
|
|
KeyMapper m;
|
|
m.setTextEntry(typing);
|
|
auto ev = press(m, withFn({'h'}));
|
|
TEST_ASSERT_EQUAL(1, ev.size());
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Help), static_cast<int>(ev[0].key));
|
|
}
|
|
}
|
|
|
|
// Issue #2: a key that isn't a character says what was held with it. A terminal sends Page Up for
|
|
// Shift with the up arrow, and a note jumps to its start for Ctrl with it.
|
|
void test_arrows_enter_and_tab_carry_what_was_held() {
|
|
KeyMapper m;
|
|
m.setTextEntry(true);
|
|
RawKeys r = withFn({';'});
|
|
r.shift = true;
|
|
auto ev = press(m, r);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(ev[0].key));
|
|
TEST_ASSERT_TRUE(ev[0].shift);
|
|
TEST_ASSERT_FALSE(ev[0].ctrl);
|
|
|
|
KeyMapper m2;
|
|
RawKeys tab;
|
|
tab.tab = true;
|
|
tab.shift = true;
|
|
ev = press(m2, tab);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Tab), static_cast<int>(ev[0].key));
|
|
TEST_ASSERT_TRUE(ev[0].shift);
|
|
|
|
KeyMapper m3;
|
|
m3.setTextEntry(true);
|
|
RawKeys back = chars({'`'});
|
|
back.alt = true;
|
|
ev = press(m3, back);
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(ev[0].key));
|
|
TEST_ASSERT_TRUE(ev[0].alt);
|
|
}
|
|
|
|
// Issue #83: Fn+p everywhere.
|
|
void test_fn_p_is_a_screenshot_in_both_modes() {
|
|
for (bool typing : {true, false}) {
|
|
KeyMapper m;
|
|
m.setTextEntry(typing);
|
|
auto ev = press(m, withFn({'p'}));
|
|
TEST_ASSERT_EQUAL(1, ev.size());
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Screenshot), static_cast<int>(ev[0].key));
|
|
release(m);
|
|
ev = press(m, chars({'p'}));
|
|
TEST_ASSERT_EQUAL(1, ev.size());
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
|
|
TEST_ASSERT_EQUAL('p', ev[0].ch);
|
|
}
|
|
}
|
|
|
|
void test_plain_h_still_types() {
|
|
for (bool typing : {true, false}) {
|
|
KeyMapper m;
|
|
m.setTextEntry(typing);
|
|
auto ev = press(m, chars({'h'}));
|
|
TEST_ASSERT_EQUAL(1, ev.size());
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
|
|
TEST_ASSERT_EQUAL('h', ev[0].ch);
|
|
}
|
|
}
|
|
|
|
void test_question_mark_is_help_only_when_not_typing() {
|
|
KeyMapper m;
|
|
m.setTextEntry(false);
|
|
auto ev = press(m, chars({'?'}));
|
|
TEST_ASSERT_EQUAL(1, ev.size());
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Help), static_cast<int>(ev[0].key));
|
|
release(m);
|
|
m.setTextEntry(true);
|
|
ev = press(m, chars({'?'}));
|
|
TEST_ASSERT_EQUAL(1, ev.size());
|
|
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
|
|
TEST_ASSERT_EQUAL('?', ev[0].ch);
|
|
}
|
|
|
|
int main() {
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_plain_character);
|
|
RUN_TEST(test_held_key_is_not_repeated_on_later_changes);
|
|
RUN_TEST(test_same_key_again_after_release);
|
|
RUN_TEST(test_fn_combos_are_arrows);
|
|
RUN_TEST(test_backtick_is_back_and_fn_backtick_is_home);
|
|
RUN_TEST(test_enter_delete_tab);
|
|
RUN_TEST(test_ctrl_and_alt_flags_are_carried);
|
|
RUN_TEST(test_compose_acute);
|
|
RUN_TEST(test_compose_while_holding_opt);
|
|
RUN_TEST(test_compose_table);
|
|
RUN_TEST(test_unknown_combination_types_both_characters);
|
|
RUN_TEST(test_opt_followed_by_a_non_accent_types_it_normally);
|
|
RUN_TEST(test_pressing_opt_twice_cancels);
|
|
RUN_TEST(test_compose_state_is_visible_for_the_ui);
|
|
RUN_TEST(test_arrow_keys_work_without_fn_when_not_typing);
|
|
RUN_TEST(test_arrow_keys_type_characters_during_text_entry);
|
|
RUN_TEST(test_fn_arrows_work_in_both_modes);
|
|
RUN_TEST(test_other_characters_still_type_when_not_typing);
|
|
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
|
|
RUN_TEST(test_fn_h_is_help_in_both_modes);
|
|
RUN_TEST(test_arrows_enter_and_tab_carry_what_was_held);
|
|
RUN_TEST(test_fn_p_is_a_screenshot_in_both_modes);
|
|
RUN_TEST(test_plain_h_still_types);
|
|
RUN_TEST(test_question_mark_is_help_only_when_not_typing);
|
|
return UNITY_END();
|
|
}
|