From bfa43a1eedc0b7e03f58a76c21ab586f507ed350 Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Mon, 7 Sep 2026 20:06:18 -0500 Subject: [PATCH] feat: on-device menu core, Device Info, Factory Reset (PR 6/9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the on-device menu system: hold Left Up+Down together for 1s to open it, then Left Up/Down scroll, Stick Click confirms, Bumper backs out. All pure logic — MenuController (menu.h/.cpp) knows nothing about real buttons or the display; SnipsController.ino translates button edges into its four nav calls, and renderMenuScreen() turns menu state into a ScreenBuffer for the existing OledDisplay to draw. Menu tree covers everything that's genuinely local and has everything it needs already built: - Calibrate Stick / Calibrate Trigger — wires up the guided-calibration flows built in PR 5 (previously unused), persisting results via CalibrationStore - Device Info — placeholder pending PR 8's XBee SPI transport, which is what will actually let this query the module's SL - Factory Reset — confirm-gated, currently clears calibration data; extends to the droid list once DroidStore exists (PR 7) Switch Droid/Manage Droids (PR 7) and Display Config (PR 9) aren't part of this menu tree yet — they need subsystems that don't exist. Also adds TextEntryWidget (text_entry.h/.cpp), the reusable one- character-at-a-time entry widget (full alphanumeric charset, or hex-only) that PR 7's Manage Droids screen will need for name/PAN ID entry — built now alongside the rest of the menu framework per the rewrite plan, not yet wired to anything. Co-Authored-By: Claude Sonnet 5 --- include/menu.h | 110 +++++++ include/text_entry.h | 55 ++++ src/SnipsController.ino | 98 +++++- src/menu.cpp | 251 ++++++++++++++++ src/text_entry.cpp | 54 ++++ test/test_menu/test_menu.cpp | 366 +++++++++++++++++++++++ test/test_text_entry/test_text_entry.cpp | 149 +++++++++ 7 files changed, 1074 insertions(+), 9 deletions(-) create mode 100644 include/menu.h create mode 100644 include/text_entry.h create mode 100644 src/menu.cpp create mode 100644 src/text_entry.cpp create mode 100644 test/test_menu/test_menu.cpp create mode 100644 test/test_text_entry/test_text_entry.cpp diff --git a/include/menu.h b/include/menu.h new file mode 100644 index 0000000..2a592b8 --- /dev/null +++ b/include/menu.h @@ -0,0 +1,110 @@ +#pragma once + +#include "calibration.h" +#include "screen.h" + +// Pure on-device menu state machine. Knows nothing about real buttons or +// the display — SnipsController.ino translates physical button edges into +// the four nav calls below (and detects the open combo via +// updateOpenCombo()), and renderMenuScreen() turns the current state into +// a ScreenBuffer for the (already-existing, hardware-touching) OledDisplay +// to draw. +// +// Only screens with everything they need already built land here: Manage/ +// Switch Droid (PR 7) and Display Config (PR 9) aren't part of this menu +// tree yet. +enum class MenuScreen { + kInactive, + kMainMenu, + kCalibrateStick, + kCalibrateTrigger, + kDeviceInfo, + kFactoryResetConfirm, +}; + +enum class MainMenuItem { + kCalibrateStick = 0, + kCalibrateTrigger, + kDeviceInfo, + kFactoryReset, + kCount, +}; + +const char *mainMenuItemLabel(MainMenuItem item); + +class MenuController { + public: + MenuScreen currentScreen() const { return screen_; } + MainMenuItem selectedMainMenuItem() const; + + // Held-combo detection to open the menu from kInactive — call every + // loop tick with the current debounced state of the two buttons this + // controller uses to enter the menu, regardless of currentScreen(). + void updateOpenCombo(bool comboButtonAPressed, bool comboButtonBPressed, + unsigned long nowMs); + + // Discrete nav events — call at most once per tick, only on a fresh + // button-press edge (not while held). + void onUp(); + void onDown(); + void onBack(); + + // Enter needs the current raw analog readings so the calibration + // screens can capture a sample at the moment of confirmation; ignored + // by every other screen. + void onEnter(int rawTrigger, int rawStickX, int rawStickY); + + // Call every loop tick regardless of button edges, so the stick + // calibration's "roll to extremes" step can continuously track + // min/max. No-op unless currentScreen() == kCalibrateStick and its + // internal flow is in the rolling step. + void tick(int rawStickX, int rawStickY); + + TriggerCalibrationFlow::Step triggerCalibrationStep() const { + return triggerFlow_.currentStep(); + } + StickCalibrationFlow::Step stickCalibrationStep() const { + return stickFlow_.currentStep(); + } + + // Each returns true exactly once, the tick a new result becomes ready + // to persist, and writes it into the output params. + bool consumeNewTriggerCalibration(int *outMin, int *outMax); + bool consumeNewStickCalibration(int *outCenterX, int *outCenterY, + int *outMinX, int *outMaxX, int *outMinY, + int *outMaxY); + bool consumeFactoryResetConfirmed(); + + private: + static constexpr unsigned long kOpenComboHoldMs = 1000; + + void open(); + void enterMainMenuItem(MainMenuItem item); + + MenuScreen screen_ = MenuScreen::kInactive; + int mainMenuIndex_ = 0; + + bool comboHeld_ = false; + unsigned long comboStartMs_ = 0; + + TriggerCalibrationFlow triggerFlow_; + StickCalibrationFlow stickFlow_; + + bool hasNewTriggerCalibration_ = false; + int pendingTriggerMin_ = 0; + int pendingTriggerMax_ = 0; + + bool hasNewStickCalibration_ = false; + int pendingStickCenterX_ = 0; + int pendingStickCenterY_ = 0; + int pendingStickMinX_ = 0; + int pendingStickMaxX_ = 0; + int pendingStickMinY_ = 0; + int pendingStickMaxY_ = 0; + + bool factoryResetConfirmed_ = false; +}; + +// Decides what text should be on screen for the menu's current state. +// Pure — takes no display dependency, just fills in a ScreenBuffer. +void renderMenuScreen(const MenuController &menu, ScreenBuffer *screen); diff --git a/include/text_entry.h b/include/text_entry.h new file mode 100644 index 0000000..9061b0b --- /dev/null +++ b/include/text_entry.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +// Reusable one-character-at-a-time entry widget ("scroll wheel" style, +// like old feature-phone name entry): scroll to a character, commit it, +// repeat. A trailing "done" position past the last real character in the +// charset finishes entry. Pure logic — no display or button-mapping +// decisions live here; the menu screen that owns an instance decides how +// scroll/commit/backspace map to physical buttons and how to render the +// current highlight. +class TextEntryWidget { + public: + enum class CharSet { kAlphanumeric, kHex }; + + explicit TextEntryWidget(CharSet charSet, size_t maxLength = 20); + + // Cycles the current slot's highlight forward/backward through the + // charset, wrapping at both ends (including the trailing "done" + // position). No-op once done(). + void scrollNext(); + void scrollPrev(); + + // Commits the current highlight into the buffer and advances to a new + // slot (highlight resets to the first charset character), unless the + // current highlight is the "done" position, which finishes entry + // instead — see done(). No-op once done(), and no-op if the buffer is + // already at maxLength and the highlight isn't "done". + void commitChar(); + + // Removes the last committed character. No-op if empty or once done(). + void backspace(); + + // True when the current highlight is the trailing "done" position — + // the caller should show "DONE" rather than calling currentChar(). + bool isDoneSelected() const; + + // Valid only when !isDoneSelected(). + char currentChar() const; + + bool done() const { return done_; } + const char *text() const { return buffer_; } + size_t length() const { return length_; } + + private: + static constexpr size_t kMaxBufferLength = 20; + + const char *charset_; + size_t charsetLength_; + size_t cursor_ = 0; // 0..charsetLength_ inclusive; charsetLength_ = done + char buffer_[kMaxBufferLength + 1] = {}; + size_t length_ = 0; + size_t maxLength_; + bool done_ = false; +}; diff --git a/src/SnipsController.ino b/src/SnipsController.ino index 8bedf0b..6b7dd8b 100644 --- a/src/SnipsController.ino +++ b/src/SnipsController.ino @@ -4,6 +4,7 @@ #include "buttons.h" #include "calibration.h" #include "calibration_store.h" +#include "menu.h" #include "oled.h" #include "pin_assignment.h" #include "power_latch.h" @@ -24,9 +25,19 @@ RgbLed rgbLed; StatusLedController statusLedController; BatteryMonitor batteryMonitor; CalibrationData calibrationData; +MenuController menuController; bool lastReportedPressed[Buttons::kCount] = {}; unsigned long lastTelemetryLogMs = 0; constexpr unsigned long kTelemetryLogIntervalMs = 1000; +MenuScreen previousMenuScreen = MenuScreen::kInactive; +MainMenuItem previousMainMenuItem = MainMenuItem::kCalibrateStick; + +void showBootScreen() { + ScreenBuffer bootScreen; + bootScreen.setLine(0, "Snips Controller"); + bootScreen.setLine(1, "OLED OK"); + oledDisplay.render(bootScreen); +} const char *buttonName(size_t index) { switch (index) { @@ -96,13 +107,11 @@ void setup() { // get wired to the on-device menu in a later PR. calibrationData = CalibrationStore::load(); - // Real screen content (menus, complications, gesture feedback) lands in - // later PRs. For now this just proves the display works end to end. + // Real "normal operating" screen content (complications) lands in a + // later PR. For now this just proves the display works end to end, and + // is what's restored whenever the on-device menu closes. if (oledDisplay.begin()) { - ScreenBuffer bootScreen; - bootScreen.setLine(0, "Snips Controller"); - bootScreen.setLine(1, "OLED OK"); - oledDisplay.render(bootScreen); + showBootScreen(); } else { Serial.println("OLED not found at boot."); } @@ -139,11 +148,21 @@ void loop() { "Power button held 3s - shutdown sequence would run here (PR 9)."); } + // Read every tick (not just on the telemetry throttle below) — the menu + // needs a fresh sample at the exact moment of each button press for + // calibration, and the stick's "roll to extremes" step needs continuous + // per-tick sampling. + const int rawTrigger = analogRead(PinAssignment::kAnalogTrigger); + const int rawStickX = analogRead(PinAssignment::kThumbstickX); + const int rawStickY = analogRead(PinAssignment::kThumbstickY); + + bool justPressed[Buttons::kCount] = {}; for (size_t i = 0; i < Buttons::kCount; ++i) { const bool rawPressed = digitalRead(Buttons::kPins[i]) == LOW; buttonPanel.update(i, rawPressed, now); const bool pressed = buttonPanel.isPressed(i); + justPressed[i] = pressed && !lastReportedPressed[i]; if (pressed != lastReportedPressed[i]) { lastReportedPressed[i] = pressed; Serial.print(buttonName(i)); @@ -151,14 +170,75 @@ void loop() { } } + // On-device menu: Left Up+Down held together opens it; once open, Left + // Up/Down scroll, Stick Click confirms, Bumper backs out. These four + // buttons are "stolen" for navigation only while the menu is active — + // Amidala never sees them any differently either way, since the packet + // protocol (PR 8) doesn't exist yet. + menuController.updateOpenCombo(buttonPanel.isPressed(Buttons::kLeftUp), + buttonPanel.isPressed(Buttons::kLeftDown), + now); + menuController.tick(rawStickX, rawStickY); + if (justPressed[Buttons::kLeftUp]) menuController.onUp(); + if (justPressed[Buttons::kLeftDown]) menuController.onDown(); + if (justPressed[Buttons::kStickClick]) { + menuController.onEnter(rawTrigger, rawStickX, rawStickY); + } + if (justPressed[Buttons::kBumper]) menuController.onBack(); + + int newTriggerMin, newTriggerMax; + if (menuController.consumeNewTriggerCalibration(&newTriggerMin, + &newTriggerMax)) { + calibrationData.triggerMin = newTriggerMin; + calibrationData.triggerMax = newTriggerMax; + CalibrationStore::save(calibrationData); + Serial.println("Trigger calibration saved."); + } + + int newCenterX, newCenterY, newMinX, newMaxX, newMinY, newMaxY; + if (menuController.consumeNewStickCalibration(&newCenterX, &newCenterY, + &newMinX, &newMaxX, &newMinY, + &newMaxY)) { + calibrationData.stickXCenter = newCenterX; + calibrationData.stickYCenter = newCenterY; + calibrationData.stickXMin = newMinX; + calibrationData.stickXMax = newMaxX; + calibrationData.stickYMin = newMinY; + calibrationData.stickYMax = newMaxY; + CalibrationStore::save(calibrationData); + Serial.println("Stick calibration saved."); + } + + if (menuController.consumeFactoryResetConfirmed()) { + // Droid list wipe joins this once DroidStore exists (PR 7). + calibrationData = CalibrationData(); + CalibrationStore::save(calibrationData); + Serial.println("Factory reset: calibration cleared."); + } + + // Only touch the display when something actually changed — a full + // redraw every tick would be needless I2C traffic for static text. + const bool menuStateChanged = + menuController.currentScreen() != previousMenuScreen || + menuController.selectedMainMenuItem() != previousMainMenuItem; + previousMenuScreen = menuController.currentScreen(); + previousMainMenuItem = menuController.selectedMainMenuItem(); + + if (menuStateChanged) { + if (menuController.currentScreen() != MenuScreen::kInactive) { + ScreenBuffer menuScreen; + renderMenuScreen(menuController, &menuScreen); + oledDisplay.render(menuScreen); + } else { + showBootScreen(); + } + } + // Packet protocol lands in PR 8 — for now, just log periodically (not // every tick) so bring-up can confirm these readings look right. if (now - lastTelemetryLogMs >= kTelemetryLogIntervalMs) { lastTelemetryLogMs = now; - const int rawTrigger = analogRead(PinAssignment::kAnalogTrigger); - const int rawStickX = analogRead(PinAssignment::kThumbstickX); - const int rawStickY = analogRead(PinAssignment::kThumbstickY); const int rawVsys = analogRead(PinAssignment::kVsysSense); const bool stat1High = digitalRead(PinAssignment::kChargeStat1) == HIGH; const bool stat2High = digitalRead(PinAssignment::kChargeStat2) == HIGH; diff --git a/src/menu.cpp b/src/menu.cpp new file mode 100644 index 0000000..7dfcae2 --- /dev/null +++ b/src/menu.cpp @@ -0,0 +1,251 @@ +#include "menu.h" + +#include + +const char *mainMenuItemLabel(MainMenuItem item) { + switch (item) { + case MainMenuItem::kCalibrateStick: return "Calibrate Stick"; + case MainMenuItem::kCalibrateTrigger: return "Calibrate Trigger"; + case MainMenuItem::kDeviceInfo: return "Device Info"; + case MainMenuItem::kFactoryReset: return "Factory Reset"; + default: return "Unknown"; + } +} + +MainMenuItem MenuController::selectedMainMenuItem() const { + return static_cast(mainMenuIndex_); +} + +void MenuController::open() { + if (screen_ != MenuScreen::kInactive) return; + screen_ = MenuScreen::kMainMenu; + mainMenuIndex_ = 0; +} + +void MenuController::updateOpenCombo(bool comboButtonAPressed, + bool comboButtonBPressed, + unsigned long nowMs) { + if (comboButtonAPressed && comboButtonBPressed) { + if (!comboHeld_) { + comboHeld_ = true; + comboStartMs_ = nowMs; + } else if (nowMs - comboStartMs_ >= kOpenComboHoldMs) { + open(); + comboHeld_ = false; // avoid immediately retriggering + } + } else { + comboHeld_ = false; + } +} + +void MenuController::onUp() { + if (screen_ != MenuScreen::kMainMenu) return; + const int count = static_cast(MainMenuItem::kCount); + mainMenuIndex_ = (mainMenuIndex_ - 1 + count) % count; +} + +void MenuController::onDown() { + if (screen_ != MenuScreen::kMainMenu) return; + const int count = static_cast(MainMenuItem::kCount); + mainMenuIndex_ = (mainMenuIndex_ + 1) % count; +} + +void MenuController::onBack() { + switch (screen_) { + case MenuScreen::kMainMenu: + screen_ = MenuScreen::kInactive; + break; + case MenuScreen::kCalibrateStick: + stickFlow_ = StickCalibrationFlow(); + screen_ = MenuScreen::kMainMenu; + break; + case MenuScreen::kCalibrateTrigger: + triggerFlow_ = TriggerCalibrationFlow(); + screen_ = MenuScreen::kMainMenu; + break; + case MenuScreen::kDeviceInfo: + case MenuScreen::kFactoryResetConfirm: + screen_ = MenuScreen::kMainMenu; + break; + case MenuScreen::kInactive: + break; + } +} + +void MenuController::enterMainMenuItem(MainMenuItem item) { + switch (item) { + case MainMenuItem::kCalibrateStick: + stickFlow_ = StickCalibrationFlow(); + screen_ = MenuScreen::kCalibrateStick; + break; + case MainMenuItem::kCalibrateTrigger: + triggerFlow_ = TriggerCalibrationFlow(); + screen_ = MenuScreen::kCalibrateTrigger; + break; + case MainMenuItem::kDeviceInfo: + screen_ = MenuScreen::kDeviceInfo; + break; + case MainMenuItem::kFactoryReset: + screen_ = MenuScreen::kFactoryResetConfirm; + break; + case MainMenuItem::kCount: + break; + } +} + +void MenuController::onEnter(int rawTrigger, int rawStickX, int rawStickY) { + switch (screen_) { + case MenuScreen::kMainMenu: + enterMainMenuItem(selectedMainMenuItem()); + break; + + case MenuScreen::kCalibrateTrigger: + triggerFlow_.confirmStep(rawTrigger); + if (triggerFlow_.currentStep() == + TriggerCalibrationFlow::Step::kDone) { + hasNewTriggerCalibration_ = true; + pendingTriggerMin_ = triggerFlow_.min(); + pendingTriggerMax_ = triggerFlow_.max(); + screen_ = MenuScreen::kMainMenu; + } + break; + + case MenuScreen::kCalibrateStick: + if (stickFlow_.currentStep() == + StickCalibrationFlow::Step::kAwaitingCenter) { + stickFlow_.confirmCenter(rawStickX, rawStickY); + } else if (stickFlow_.currentStep() == + StickCalibrationFlow::Step::kRolling) { + stickFlow_.confirmDone(); + if (stickFlow_.currentStep() == StickCalibrationFlow::Step::kDone) { + hasNewStickCalibration_ = true; + pendingStickCenterX_ = stickFlow_.centerX(); + pendingStickCenterY_ = stickFlow_.centerY(); + pendingStickMinX_ = stickFlow_.minX(); + pendingStickMaxX_ = stickFlow_.maxX(); + pendingStickMinY_ = stickFlow_.minY(); + pendingStickMaxY_ = stickFlow_.maxY(); + screen_ = MenuScreen::kMainMenu; + } + } + break; + + case MenuScreen::kDeviceInfo: + screen_ = MenuScreen::kMainMenu; + break; + + case MenuScreen::kFactoryResetConfirm: + factoryResetConfirmed_ = true; + screen_ = MenuScreen::kMainMenu; + break; + + case MenuScreen::kInactive: + break; + } +} + +void MenuController::tick(int rawStickX, int rawStickY) { + if (screen_ == MenuScreen::kCalibrateStick && + stickFlow_.currentStep() == StickCalibrationFlow::Step::kRolling) { + stickFlow_.sample(rawStickX, rawStickY); + } +} + +bool MenuController::consumeNewTriggerCalibration(int *outMin, int *outMax) { + if (!hasNewTriggerCalibration_) return false; + *outMin = pendingTriggerMin_; + *outMax = pendingTriggerMax_; + hasNewTriggerCalibration_ = false; + return true; +} + +bool MenuController::consumeNewStickCalibration(int *outCenterX, + int *outCenterY, + int *outMinX, int *outMaxX, + int *outMinY, int *outMaxY) { + if (!hasNewStickCalibration_) return false; + *outCenterX = pendingStickCenterX_; + *outCenterY = pendingStickCenterY_; + *outMinX = pendingStickMinX_; + *outMaxX = pendingStickMaxX_; + *outMinY = pendingStickMinY_; + *outMaxY = pendingStickMaxY_; + hasNewStickCalibration_ = false; + return true; +} + +bool MenuController::consumeFactoryResetConfirmed() { + if (!factoryResetConfirmed_) return false; + factoryResetConfirmed_ = false; + return true; +} + +void renderMenuScreen(const MenuController &menu, ScreenBuffer *screen) { + screen->clear(); + + switch (menu.currentScreen()) { + case MenuScreen::kInactive: + break; // caller decides what else to show when the menu is closed + + case MenuScreen::kMainMenu: { + screen->setLine(0, "== Menu =="); + char line[ScreenBuffer::kMaxLineLength + 1]; + for (int i = 0; i < static_cast(MainMenuItem::kCount); ++i) { + const auto item = static_cast(i); + std::snprintf(line, sizeof(line), "%s%s", + i == static_cast(menu.selectedMainMenuItem()) + ? "> " + : " ", + mainMenuItemLabel(item)); + screen->setLine(1 + i, line); + } + break; + } + + case MenuScreen::kCalibrateTrigger: + screen->setLine(0, "Calibrate Trigger"); + switch (menu.triggerCalibrationStep()) { + case TriggerCalibrationFlow::Step::kAwaitingRelease: + screen->setLine(1, "Release trigger,"); + screen->setLine(2, "press Enter"); + break; + case TriggerCalibrationFlow::Step::kAwaitingFullPull: + screen->setLine(1, "Pull fully,"); + screen->setLine(2, "press Enter"); + break; + case TriggerCalibrationFlow::Step::kDone: + screen->setLine(1, "Done!"); + break; + } + break; + + case MenuScreen::kCalibrateStick: + screen->setLine(0, "Calibrate Stick"); + switch (menu.stickCalibrationStep()) { + case StickCalibrationFlow::Step::kAwaitingCenter: + screen->setLine(1, "Center stick,"); + screen->setLine(2, "press Enter"); + break; + case StickCalibrationFlow::Step::kRolling: + screen->setLine(1, "Roll to extremes,"); + screen->setLine(2, "Enter when done"); + break; + case StickCalibrationFlow::Step::kDone: + screen->setLine(1, "Done!"); + break; + } + break; + + case MenuScreen::kDeviceInfo: + screen->setLine(0, "Device Info"); + screen->setLine(1, "XBee SL:"); + screen->setLine(2, "(needs PR 8)"); + break; + + case MenuScreen::kFactoryResetConfirm: + screen->setLine(0, "Factory Reset?"); + screen->setLine(1, "Enter = confirm"); + screen->setLine(2, "Back = cancel"); + break; + } +} diff --git a/src/text_entry.cpp b/src/text_entry.cpp new file mode 100644 index 0000000..5ecc6eb --- /dev/null +++ b/src/text_entry.cpp @@ -0,0 +1,54 @@ +#include "text_entry.h" + +namespace { +constexpr const char *kAlphanumericCharset = + " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +constexpr const char *kHexCharset = "0123456789ABCDEF"; +} // namespace + +TextEntryWidget::TextEntryWidget(CharSet charSet, size_t maxLength) { + if (charSet == CharSet::kHex) { + charset_ = kHexCharset; + charsetLength_ = 16; + } else { + charset_ = kAlphanumericCharset; + charsetLength_ = 37; + } + maxLength_ = maxLength > kMaxBufferLength ? kMaxBufferLength : maxLength; +} + +void TextEntryWidget::scrollNext() { + if (done_) return; + cursor_ = (cursor_ + 1) % (charsetLength_ + 1); +} + +void TextEntryWidget::scrollPrev() { + if (done_) return; + cursor_ = (cursor_ == 0) ? charsetLength_ : cursor_ - 1; +} + +void TextEntryWidget::commitChar() { + if (done_) return; + + if (isDoneSelected()) { + done_ = true; + return; + } + + if (length_ >= maxLength_) { + return; // buffer full — only "done" is a valid path forward + } + + buffer_[length_++] = charset_[cursor_]; + buffer_[length_] = '\0'; + cursor_ = 0; +} + +void TextEntryWidget::backspace() { + if (done_ || length_ == 0) return; + buffer_[--length_] = '\0'; +} + +bool TextEntryWidget::isDoneSelected() const { return cursor_ == charsetLength_; } + +char TextEntryWidget::currentChar() const { return charset_[cursor_]; } diff --git a/test/test_menu/test_menu.cpp b/test/test_menu/test_menu.cpp new file mode 100644 index 0000000..8dcb3a4 --- /dev/null +++ b/test/test_menu/test_menu.cpp @@ -0,0 +1,366 @@ +#include +#include + +#include "menu.h" + +void setUp(void) {} +void tearDown(void) {} + +// ---- open combo ----------------------------------------------------------- + +void test_starts_inactive() { + MenuController menu; + TEST_ASSERT_TRUE(MenuScreen::kInactive == menu.currentScreen()); +} + +void test_combo_held_shorter_than_threshold_does_not_open() { + MenuController menu; + menu.updateOpenCombo(true, true, 0); + menu.updateOpenCombo(true, true, 999); + TEST_ASSERT_TRUE(MenuScreen::kInactive == menu.currentScreen()); +} + +void test_combo_held_past_threshold_opens_main_menu() { + MenuController menu; + menu.updateOpenCombo(true, true, 0); + menu.updateOpenCombo(true, true, 1000); + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); +} + +void test_combo_releasing_before_threshold_resets_timer() { + MenuController menu; + menu.updateOpenCombo(true, true, 0); + menu.updateOpenCombo(true, false, 500); // released early + menu.updateOpenCombo(true, true, 1000); // re-held, only 0ms elapsed so far + TEST_ASSERT_TRUE(MenuScreen::kInactive == menu.currentScreen()); + menu.updateOpenCombo(true, true, 1999); + TEST_ASSERT_TRUE(MenuScreen::kInactive == menu.currentScreen()); + menu.updateOpenCombo(true, true, 2000); + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); +} + +namespace { +void openMenu(MenuController *menu) { + menu->updateOpenCombo(true, true, 0); + menu->updateOpenCombo(true, true, 1000); +} +} // namespace + +// ---- main menu navigation -------------------------------------------------- + +void test_up_down_noop_while_inactive() { + MenuController menu; + menu.onUp(); + menu.onDown(); + TEST_ASSERT_TRUE(MenuScreen::kInactive == menu.currentScreen()); +} + +void test_down_wraps_around_main_menu() { + MenuController menu; + openMenu(&menu); + TEST_ASSERT_TRUE(MainMenuItem::kCalibrateStick == + menu.selectedMainMenuItem()); + menu.onDown(); + TEST_ASSERT_TRUE(MainMenuItem::kCalibrateTrigger == + menu.selectedMainMenuItem()); + menu.onDown(); + TEST_ASSERT_TRUE(MainMenuItem::kDeviceInfo == menu.selectedMainMenuItem()); + menu.onDown(); + TEST_ASSERT_TRUE(MainMenuItem::kFactoryReset == + menu.selectedMainMenuItem()); + menu.onDown(); // wraps back to the first item + TEST_ASSERT_TRUE(MainMenuItem::kCalibrateStick == + menu.selectedMainMenuItem()); +} + +void test_up_wraps_around_main_menu() { + MenuController menu; + openMenu(&menu); + menu.onUp(); // wraps to the last item + TEST_ASSERT_TRUE(MainMenuItem::kFactoryReset == + menu.selectedMainMenuItem()); +} + +void test_back_from_main_menu_closes() { + MenuController menu; + openMenu(&menu); + menu.onBack(); + TEST_ASSERT_TRUE(MenuScreen::kInactive == menu.currentScreen()); +} + +void test_back_and_enter_are_noop_while_inactive() { + MenuController menu; + menu.onBack(); + menu.onEnter(0, 0, 0); + TEST_ASSERT_TRUE(MenuScreen::kInactive == menu.currentScreen()); +} + +void test_back_during_stick_calibration_cancels_and_resets() { + MenuController menu; + openMenu(&menu); + menu.onEnter(0, 0, 0); // -> kCalibrateStick + menu.onEnter(0, 2000, 2100); // confirm center, now rolling + menu.onBack(); + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); + + // Re-entering starts a fresh flow, not resuming the cancelled one. + menu.onEnter(0, 0, 0); + TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kAwaitingCenter == + menu.stickCalibrationStep()); +} + +// ---- entering leaf screens -------------------------------------------------- + +void test_enter_device_info_from_main_menu() { + MenuController menu; + openMenu(&menu); + menu.onDown(); + menu.onDown(); // -> kDeviceInfo + menu.onEnter(0, 0, 0); + TEST_ASSERT_TRUE(MenuScreen::kDeviceInfo == menu.currentScreen()); +} + +void test_device_info_enter_or_back_returns_to_main_menu() { + MenuController menu; + openMenu(&menu); + menu.onDown(); + menu.onDown(); + menu.onEnter(0, 0, 0); + menu.onEnter(0, 0, 0); + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); +} + +void test_enter_factory_reset_confirm_then_back_cancels() { + MenuController menu; + openMenu(&menu); + menu.onUp(); // -> kFactoryReset (wraps to last item) + menu.onEnter(0, 0, 0); + TEST_ASSERT_TRUE(MenuScreen::kFactoryResetConfirm == menu.currentScreen()); + + menu.onBack(); + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); + TEST_ASSERT_FALSE(menu.consumeFactoryResetConfirmed()); +} + +void test_factory_reset_confirmed_via_enter() { + MenuController menu; + openMenu(&menu); + menu.onUp(); + menu.onEnter(0, 0, 0); // -> confirm screen + menu.onEnter(0, 0, 0); // confirms + + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); + TEST_ASSERT_TRUE(menu.consumeFactoryResetConfirmed()); + // Consuming is edge-triggered — a second call returns false. + TEST_ASSERT_FALSE(menu.consumeFactoryResetConfirmed()); +} + +// ---- trigger calibration --------------------------------------------------- + +void test_trigger_calibration_full_flow() { + MenuController menu; + openMenu(&menu); + menu.onDown(); // -> kCalibrateTrigger + menu.onEnter(0, 0, 0); + TEST_ASSERT_TRUE(MenuScreen::kCalibrateTrigger == menu.currentScreen()); + TEST_ASSERT_TRUE(TriggerCalibrationFlow::Step::kAwaitingRelease == + menu.triggerCalibrationStep()); + + menu.onEnter(50, 0, 0); // capture release + TEST_ASSERT_TRUE(TriggerCalibrationFlow::Step::kAwaitingFullPull == + menu.triggerCalibrationStep()); + + menu.onEnter(4000, 0, 0); // capture full pull -> done + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); + + int min = -1, max = -1; + TEST_ASSERT_TRUE(menu.consumeNewTriggerCalibration(&min, &max)); + TEST_ASSERT_EQUAL_INT(50, min); + TEST_ASSERT_EQUAL_INT(4000, max); + TEST_ASSERT_FALSE(menu.consumeNewTriggerCalibration(&min, &max)); +} + +void test_back_during_trigger_calibration_cancels_and_resets() { + MenuController menu; + openMenu(&menu); + menu.onDown(); + menu.onEnter(0, 0, 0); + menu.onEnter(50, 0, 0); // partway through + menu.onBack(); + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); + + // Re-entering starts a fresh flow, not resuming the cancelled one. + menu.onDown(); + menu.onEnter(0, 0, 0); + TEST_ASSERT_TRUE(TriggerCalibrationFlow::Step::kAwaitingRelease == + menu.triggerCalibrationStep()); +} + +// ---- stick calibration ------------------------------------------------------ + +void test_stick_calibration_full_flow() { + MenuController menu; + openMenu(&menu); + menu.onEnter(0, 0, 0); // -> kCalibrateStick (first item) + TEST_ASSERT_TRUE(MenuScreen::kCalibrateStick == menu.currentScreen()); + + menu.onEnter(0, 2000, 2100); // confirm center + TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kRolling == + menu.stickCalibrationStep()); + + menu.tick(1500, 2600); + menu.tick(2500, 1800); + menu.onEnter(0, 9999, 9999); // confirm done (values here are ignored) + + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); + + int centerX, centerY, minX, maxX, minY, maxY; + TEST_ASSERT_TRUE(menu.consumeNewStickCalibration(¢erX, ¢erY, &minX, + &maxX, &minY, &maxY)); + TEST_ASSERT_EQUAL_INT(2000, centerX); + TEST_ASSERT_EQUAL_INT(2100, centerY); + TEST_ASSERT_EQUAL_INT(1500, minX); + TEST_ASSERT_EQUAL_INT(2500, maxX); + TEST_ASSERT_EQUAL_INT(1800, minY); + TEST_ASSERT_EQUAL_INT(2600, maxY); + TEST_ASSERT_FALSE( + menu.consumeNewStickCalibration(¢erX, ¢erY, &minX, &maxX, + &minY, &maxY)); +} + +void test_tick_is_noop_outside_rolling_stick_calibration() { + MenuController menu; + openMenu(&menu); + // Menu is open but not even in the stick screen yet. + menu.tick(1234, 5678); + menu.onEnter(0, 0, 0); // -> kCalibrateStick, awaiting center + menu.tick(1234, 5678); // still awaiting center, not rolling + menu.onEnter(0, 2000, 2100); // confirm center -> rolling + int centerX, centerY, minX, maxX, minY, maxY; + menu.onEnter(0, 0, 0); // confirm done immediately, no samples taken + TEST_ASSERT_TRUE(menu.consumeNewStickCalibration(¢erX, ¢erY, &minX, + &maxX, &minY, &maxY)); + // With no rolling samples, min/max should stay collapsed to the center. + TEST_ASSERT_EQUAL_INT(2000, minX); + TEST_ASSERT_EQUAL_INT(2000, maxX); +} + +// ---- labels ------------------------------------------------------------------ + +void test_main_menu_item_labels() { + TEST_ASSERT_EQUAL_STRING("Calibrate Stick", + mainMenuItemLabel(MainMenuItem::kCalibrateStick)); + TEST_ASSERT_EQUAL_STRING("Calibrate Trigger", + mainMenuItemLabel(MainMenuItem::kCalibrateTrigger)); + TEST_ASSERT_EQUAL_STRING("Device Info", + mainMenuItemLabel(MainMenuItem::kDeviceInfo)); + TEST_ASSERT_EQUAL_STRING("Factory Reset", + mainMenuItemLabel(MainMenuItem::kFactoryReset)); + TEST_ASSERT_EQUAL_STRING("Unknown", + mainMenuItemLabel(MainMenuItem::kCount)); +} + +// ---- rendering ---------------------------------------------------------------- + +void test_render_inactive_leaves_screen_blank() { + MenuController menu; + ScreenBuffer screen; + screen.setLine(0, "stale content"); + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("", screen.line(0)); +} + +void test_render_main_menu_marks_selected_item() { + MenuController menu; + ScreenBuffer screen; + openMenu(&menu); + menu.onDown(); // select Calibrate Trigger + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING(" Calibrate Stick", screen.line(1)); + TEST_ASSERT_EQUAL_STRING("> Calibrate Trigger", screen.line(2)); +} + +void test_render_trigger_calibration_step_text() { + MenuController menu; + ScreenBuffer screen; + openMenu(&menu); + menu.onDown(); + menu.onEnter(0, 0, 0); + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("Release trigger,", screen.line(1)); + + menu.onEnter(50, 0, 0); // advance to the full-pull step + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("Pull fully,", screen.line(1)); +} + +void test_render_stick_calibration_awaiting_center_step_text() { + MenuController menu; + ScreenBuffer screen; + openMenu(&menu); + menu.onEnter(0, 0, 0); // -> kCalibrateStick, awaiting center + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("Center stick,", screen.line(1)); +} + +void test_render_stick_calibration_rolling_step_text() { + MenuController menu; + ScreenBuffer screen; + openMenu(&menu); + menu.onEnter(0, 0, 0); // -> kCalibrateStick + menu.onEnter(0, 2000, 2100); // confirm center, now rolling + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("Roll to extremes,", screen.line(1)); +} + +void test_render_device_info() { + MenuController menu; + ScreenBuffer screen; + openMenu(&menu); + menu.onDown(); + menu.onDown(); + menu.onEnter(0, 0, 0); + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("Device Info", screen.line(0)); +} + +void test_render_factory_reset_confirm() { + MenuController menu; + ScreenBuffer screen; + openMenu(&menu); + menu.onUp(); + menu.onEnter(0, 0, 0); + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("Factory Reset?", screen.line(0)); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_starts_inactive); + RUN_TEST(test_combo_held_shorter_than_threshold_does_not_open); + RUN_TEST(test_combo_held_past_threshold_opens_main_menu); + RUN_TEST(test_combo_releasing_before_threshold_resets_timer); + RUN_TEST(test_up_down_noop_while_inactive); + RUN_TEST(test_down_wraps_around_main_menu); + RUN_TEST(test_up_wraps_around_main_menu); + RUN_TEST(test_back_from_main_menu_closes); + RUN_TEST(test_back_and_enter_are_noop_while_inactive); + RUN_TEST(test_back_during_stick_calibration_cancels_and_resets); + RUN_TEST(test_enter_device_info_from_main_menu); + RUN_TEST(test_device_info_enter_or_back_returns_to_main_menu); + RUN_TEST(test_enter_factory_reset_confirm_then_back_cancels); + RUN_TEST(test_factory_reset_confirmed_via_enter); + RUN_TEST(test_trigger_calibration_full_flow); + RUN_TEST(test_back_during_trigger_calibration_cancels_and_resets); + RUN_TEST(test_stick_calibration_full_flow); + RUN_TEST(test_tick_is_noop_outside_rolling_stick_calibration); + RUN_TEST(test_main_menu_item_labels); + RUN_TEST(test_render_inactive_leaves_screen_blank); + RUN_TEST(test_render_main_menu_marks_selected_item); + RUN_TEST(test_render_trigger_calibration_step_text); + RUN_TEST(test_render_stick_calibration_awaiting_center_step_text); + RUN_TEST(test_render_stick_calibration_rolling_step_text); + RUN_TEST(test_render_device_info); + RUN_TEST(test_render_factory_reset_confirm); + return UNITY_END(); +} diff --git a/test/test_text_entry/test_text_entry.cpp b/test/test_text_entry/test_text_entry.cpp new file mode 100644 index 0000000..c2f8427 --- /dev/null +++ b/test/test_text_entry/test_text_entry.cpp @@ -0,0 +1,149 @@ +#include +#include + +#include "text_entry.h" + +void setUp(void) {} +void tearDown(void) {} + +void test_starts_empty_and_not_done() { + TextEntryWidget widget(TextEntryWidget::CharSet::kAlphanumeric); + TEST_ASSERT_EQUAL_STRING("", widget.text()); + TEST_ASSERT_EQUAL_INT(0, widget.length()); + TEST_ASSERT_FALSE(widget.done()); + TEST_ASSERT_FALSE(widget.isDoneSelected()); +} + +void test_starts_highlighting_first_charset_char() { + TextEntryWidget widget(TextEntryWidget::CharSet::kAlphanumeric); + // First alphanumeric charset character is a space. + TEST_ASSERT_EQUAL_INT(' ', widget.currentChar()); +} + +void test_scroll_next_advances_through_charset() { + TextEntryWidget widget(TextEntryWidget::CharSet::kAlphanumeric); + widget.scrollNext(); // space -> 'A' + TEST_ASSERT_EQUAL_INT('A', widget.currentChar()); + widget.scrollNext(); // 'A' -> 'B' + TEST_ASSERT_EQUAL_INT('B', widget.currentChar()); +} + +void test_scroll_prev_from_start_wraps_to_done() { + TextEntryWidget widget(TextEntryWidget::CharSet::kAlphanumeric); + widget.scrollPrev(); + TEST_ASSERT_TRUE(widget.isDoneSelected()); +} + +void test_scroll_next_from_last_char_wraps_to_done_then_back_to_start() { + TextEntryWidget widget(TextEntryWidget::CharSet::kHex); + // Hex charset has 16 entries (0-9, A-F); scroll to the last one ('F'). + for (int i = 0; i < 15; ++i) widget.scrollNext(); + TEST_ASSERT_EQUAL_INT('F', widget.currentChar()); + + widget.scrollNext(); // 'F' -> done + TEST_ASSERT_TRUE(widget.isDoneSelected()); + + widget.scrollNext(); // done -> wraps back to '0' + TEST_ASSERT_EQUAL_INT('0', widget.currentChar()); +} + +void test_commit_char_appends_and_resets_highlight() { + TextEntryWidget widget(TextEntryWidget::CharSet::kHex); + widget.scrollNext(); // '0' -> '1' + widget.commitChar(); + TEST_ASSERT_EQUAL_STRING("1", widget.text()); + TEST_ASSERT_EQUAL_INT(1, widget.length()); + // Highlight resets to the first charset character for the next slot. + TEST_ASSERT_EQUAL_INT('0', widget.currentChar()); +} + +void test_commit_multiple_chars_builds_string() { + TextEntryWidget widget(TextEntryWidget::CharSet::kHex); + widget.scrollNext(); + widget.scrollNext(); + widget.commitChar(); // '2' + widget.scrollNext(); + widget.scrollNext(); + widget.scrollNext(); + widget.scrollNext(); + widget.commitChar(); // '4' + TEST_ASSERT_EQUAL_STRING("24", widget.text()); +} + +void test_commit_done_finishes_without_appending() { + TextEntryWidget widget(TextEntryWidget::CharSet::kHex); + widget.commitChar(); // '0' + widget.scrollPrev(); // '0' -> done + TEST_ASSERT_TRUE(widget.isDoneSelected()); + widget.commitChar(); + TEST_ASSERT_TRUE(widget.done()); + TEST_ASSERT_EQUAL_STRING("0", widget.text()); +} + +void test_backspace_removes_last_char() { + TextEntryWidget widget(TextEntryWidget::CharSet::kHex); + widget.commitChar(); // '0' + widget.scrollNext(); + widget.commitChar(); // '1' + widget.backspace(); + TEST_ASSERT_EQUAL_STRING("0", widget.text()); + TEST_ASSERT_EQUAL_INT(1, widget.length()); +} + +void test_backspace_on_empty_is_noop() { + TextEntryWidget widget(TextEntryWidget::CharSet::kHex); + widget.backspace(); + TEST_ASSERT_EQUAL_STRING("", widget.text()); +} + +void test_commit_char_respects_max_length() { + TextEntryWidget widget(TextEntryWidget::CharSet::kHex, 2); + widget.commitChar(); // '0' + widget.commitChar(); // '0' + TEST_ASSERT_EQUAL_INT(2, widget.length()); + // Buffer is full — committing a non-done char is a no-op. + widget.commitChar(); + TEST_ASSERT_EQUAL_INT(2, widget.length()); + TEST_ASSERT_EQUAL_STRING("00", widget.text()); +} + +void test_can_still_finish_via_done_when_buffer_full() { + TextEntryWidget widget(TextEntryWidget::CharSet::kHex, 1); + widget.commitChar(); // '0', now full + widget.scrollPrev(); // '0' -> done + widget.commitChar(); + TEST_ASSERT_TRUE(widget.done()); + TEST_ASSERT_EQUAL_STRING("0", widget.text()); +} + +void test_all_actions_are_noop_once_done() { + TextEntryWidget widget(TextEntryWidget::CharSet::kHex); + widget.commitChar(); // '0' + widget.scrollPrev(); // -> done + widget.commitChar(); // finishes + TEST_ASSERT_TRUE(widget.done()); + + widget.scrollNext(); + widget.scrollPrev(); + widget.commitChar(); + widget.backspace(); + TEST_ASSERT_EQUAL_STRING("0", widget.text()); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_starts_empty_and_not_done); + RUN_TEST(test_starts_highlighting_first_charset_char); + RUN_TEST(test_scroll_next_advances_through_charset); + RUN_TEST(test_scroll_prev_from_start_wraps_to_done); + RUN_TEST(test_scroll_next_from_last_char_wraps_to_done_then_back_to_start); + RUN_TEST(test_commit_char_appends_and_resets_highlight); + RUN_TEST(test_commit_multiple_chars_builds_string); + RUN_TEST(test_commit_done_finishes_without_appending); + RUN_TEST(test_backspace_removes_last_char); + RUN_TEST(test_backspace_on_empty_is_noop); + RUN_TEST(test_commit_char_respects_max_length); + RUN_TEST(test_can_still_finish_via_done_when_buffer_full); + RUN_TEST(test_all_actions_are_noop_once_done); + return UNITY_END(); +}