diff --git a/test/test_companion_inbox.py b/test/test_companion_inbox.py index 8a05312f..4d72e9c7 100644 --- a/test/test_companion_inbox.py +++ b/test/test_companion_inbox.py @@ -289,6 +289,45 @@ int main() { implementation += extract_braced(mesh, signature) + "\n" self.compile_and_run(preamble + implementation + SCENARIOS) + def test_summary_uses_newest_visible_message_per_thread(self): + ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text() + preamble = PREAMBLE.replace("int getMsgCount() const { return 0; }", UI_MEMBERS) + implementation = extract_braced(ui, "class MsgPreviewScreen :") + ";\n" + scenarios = r''' +int main() { + using namespace mesh::ui; + resetArduinoMock(); + displayPowerPrefs().battery.mode = DisplayMode::On; + Display display; + display.servicePower(false); + UITask task(display); + MsgPreviewScreen messages(&task); + task.msg_preview = task.curr = &messages; + messages.addPreview(1, "Alice", "older pending", 0, "Public", 0); + messages.addPreview(1, "Bob", "newer delivered", 0, "Public"); + auto expect = [&](DisplayInboxMode mode, const char* wanted, const char* hidden) { + displayPowerPrefs().inbox = mode; + display.clear(); messages.renderSummary(display); + if (!display.contains(wanted) || display.contains(hidden)) { + fprintf(stderr, "summary mode=%u expected=%s hidden=%s\n", unsigned(mode), wanted, hidden); + for (const auto& line : display.lines) fprintf(stderr, "rendered: %s\n", line.text.c_str()); + } + assert(display.contains(wanted) && !display.contains(hidden)); + }; + expect(DisplayInboxMode::History, "newer delivered", "older pending"); + expect(DisplayInboxMode::Pending, "older pending", "newer delivered"); + // Unread changes the count; read previews remain browseable as documented. + displayPowerPrefs().inbox = DisplayInboxMode::History; + display.clear(); messages.render(display); + expect(DisplayInboxMode::Unread, "newer delivered", "older pending"); + assert(messages.messageCount() == 1); + messages.queueRemoved(0); + expect(DisplayInboxMode::Pending, "No messages heard", "older pending"); + expect(DisplayInboxMode::History, "newer delivered", "older pending"); +} +''' + self.compile_and_run(preamble + implementation + scenarios) + if __name__ == "__main__": unittest.main() diff --git a/test/test_companion_john.py b/test/test_companion_john.py index 1d508653..0215d1a7 100644 --- a/test/test_companion_john.py +++ b/test/test_companion_john.py @@ -10,6 +10,9 @@ import tempfile import unittest import zlib +from test_message_navigation import PREAMBLE +from test_replay_reset_integration import extract_braced + ROOT = Path(__file__).resolve().parents[1] FIXTURE = ROOT / "test/fixtures/companion_john" spec = importlib.util.spec_from_file_location("pack_john", ROOT / "tools/bible/pack_john.py") @@ -148,11 +151,93 @@ class CompanionJohnTest(unittest.TestCase): self.assertIn("#if COMPANION_FEATURE_JOHN\n#include \"JohnReaderScreen.h\"", ui) self.assertLess(ui.index("#define UI_BUTTON_READER_HINT 1"), ui.index('#include "JohnReaderScreen.h"')) - self.assertIn("&& !isJohnReaderActive()", ui) self.assertIn("else if (isJohnReaderActive())", ui) self.assertIn("c = handleLongPress(KEY_ENTER);", ui) self.assertIn("handleDoubleClick(KEY_PREV)", ui) + def test_reader_bookmark_follows_display_power_transitions(self): + ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text(encoding="utf-8") + stubs = r''' +struct Board { + bool external = false, host = false; + bool isExternalPowered() const { return external; } + bool isUsbHostConnected() const { return host; } +}; +struct Interfaces { bool takePairingRequest() { return false; } }; +struct JohnReaderScreen : Screen { int saves = 0; void flush() { ++saves; } }; +''' + members = r''' + Board* _board; + Interfaces* _interfaceManager; + uint32_t _pairing_screen_until = 0; + bool hasConnection() const { return false; } + bool isPairingScreenActive() const { return false; } + void showPairingPin() {} + void finishPairingScreen(bool) {} + void servicePairingState(); +''' + source = PREAMBLE.replace("class UITask {", stubs + "class UITask {") + source = source.replace("int getMsgCount() const { return 0; }", members) + source += extract_braced(ui, "void UITask::servicePairingState(") + "\n" + source += extract_braced(ui, "char UITask::checkDisplayOn(") + r''' +int main() { + using namespace mesh::ui; + resetArduinoMock(); + displayPowerPrefs().battery = {DisplayMode::Button, 5}; + displayPowerPrefs().usb = {DisplayMode::On, 5}; + Display display; + Screen home; + JohnReaderScreen reader; + Board board; + Interfaces interfaces; + UITask task(display); + task._board = &board; task._interfaceManager = &interfaces; + task.curr = task.home = &home; task.john_reader = &reader; + task.servicePairingState(); + assert(!display.isOn() && reader.saves == 0); + task.curr = &reader; + assert(task.checkDisplayOn(KEY_NEXT) == 0); // Wake consumes navigation. + assert(display.isOn()); + g_mock_millis = 4999; task.servicePairingState(); + assert(display.isOn() && reader.saves == 0); + g_mock_millis = 5000; task.servicePairingState(); + assert(!display.isOn()); + const int expected_saves = COMPANION_FEATURE_JOHN ? 1 : 0; + assert(reader.saves == expected_saves); + task.servicePairingState(); + assert(reader.saves == expected_saves); // No repeated writes while dark. + for (bool* plugged : {&board.external, &board.host}) { + *plugged = true; task.servicePairingState(); + assert(display.isOn()); + g_mock_millis += 10000; task.servicePairingState(); + assert(display.isOn()); // USB's always-on policy applies to the reader. + int before = reader.saves; + *plugged = false; task.servicePairingState(); + assert(!display.isOn() && reader.saves == before + expected_saves); + } + task.curr = &home; + task.checkDisplayOn(KEY_NEXT); + int before = reader.saves; + g_mock_millis += 5000; task.servicePairingState(); + assert(!display.isOn() && reader.saves == before); + task._display = nullptr; task.servicePairingState(); +} +''' + compiler = shutil.which("g++") + self.assertIsNotNone(compiler, "host G++ required") + with tempfile.TemporaryDirectory(prefix="mesh-reader-power-") as directory: + directory = Path(directory) + (directory / "power.cpp").write_text(source, encoding="utf-8") + for enabled in (0, 1): + with self.subTest(reader_enabled=enabled): + binary = directory / "power.exe" + self.run_checked([compiler, "-std=c++17", f"-DCOMPANION_FEATURE_JOHN={enabled}", + "-I" + str(ROOT / "src"), "-I" + str(ROOT / "test/mocks"), + str(directory / "power.cpp"), + str(ROOT / "src/helpers/ui/MomentaryButton.cpp"), + str(ROOT / "src/helpers/ui/DisplayDriver.cpp"), "-o", str(binary)]) + self.run_checked([str(binary)]) + def run_checked(self, command): result = subprocess.run(command, capture_output=True, encoding="utf-8", errors="backslashreplace", timeout=60) diff --git a/test/test_indicator_messages_profile.py b/test/test_indicator_messages_profile.py index 5374e08d..df475af0 100644 --- a/test/test_indicator_messages_profile.py +++ b/test/test_indicator_messages_profile.py @@ -96,7 +96,8 @@ int main() {{ return 0; }} "companionMessageElapsedMillis(entry->heard_millis)", ui ) self.assertIn("esp_timer_get_time()", ui) - self.assertIn("history.hasNewerEntryForThread(age)", ui) + # Thread selection must respect the selected inbox view. The compiled + # summary regression in test_companion_inbox covers this behavior. if __name__ == "__main__": diff --git a/test/test_logging_sleep_contract.py b/test/test_logging_sleep_contract.py index 068037ef..1be72449 100644 --- a/test/test_logging_sleep_contract.py +++ b/test/test_logging_sleep_contract.py @@ -58,6 +58,8 @@ struct MyMesh { bool millisHasNowPassed(unsigned long) const { return false; } bool getNextQueueWakeDelay(uint32_t&) const { return false; } bool getNextRetryWakeDelay(uint32_t&) const { return false; } + bool dual_radio = false; + bool isDualRadioActive() const { return dual_radio; } uint32_t limitSleepToMillisTimer(unsigned long, uint32_t secs) const { return secs; } uint32_t limitSleepToScheduledRadioWork(uint32_t secs) const { return secs; } bool hasPendingWork() const; @@ -81,6 +83,12 @@ int main() { logger._initialized = false; require(node.getPowerSaveSleepSeconds(30) == 30, "stopping MQTT did not release its sleep blocker"); + node.dual_radio = true; + require(node.hasPendingWork(), "dual-radio scanning lost its sleep blocker"); + require(node.getPowerSaveSleepSeconds(30) == 0, "dual-radio scanning can enter device sleep"); + node.dual_radio = false; + require(node.getPowerSaveSleepSeconds(30) == 30, + "single-radio mode did not release its sleep blocker"); require(node.getPowerSaveSleepSeconds(0) == 0, "zero sleep limit changed"); } catch (const std::exception& e) { std::cerr << e.what() << '\n';