Repository navigation
Add MQTTManager unit test harness and CI - #392
jamesmulcahy wants to merge 23 commits into
Conversation
Wire GoogleTest into the CMake build behind an NSPM_BUILD_TESTS option and add a tests/ directory with its own test executable. The existing `if (TEST_MODE==1)` block never ran, so the inline tests had never been built or discovered. - tests/: main.cpp initialises the (TEST_MODE) database; test_helpers.hpp has a ScopedEntity RAII helper and builds entity_data exactly as Django's rest.py writes it; light_config_test.cpp checks lights load placement, capabilities and controller from Django-shaped rows. - The inline TEST_MODE tests in the Config and Light libraries are linked in whole so they register with GoogleTest (21 tests in total). - run_tests.sh builds and runs everything in Docker, caching Conan packages and build output in named volumes. - compile_mqttmanager.sh --test now sets the CMake option instead of using sed. Fixes found by turning the tests on: - Remove the copies of the Home Assistant light tests at the end of both thermostat sources; they did not compile and duplicated test names. - Add the missing OPENHAB_RGB_CHANNEL_NAME entry to the settings key map (caught by MqttManagerConfigTest.verify_all_settings_exists_and_have_db_key). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs docker/MQTTManager/run_tests.sh on pull requests and pushes that touch docker/MQTTManager. The Conan package cache is a host directory saved with actions/cache (keyed on conanfile.py and the test Dockerfile), so only the first run pays for building the dependencies from source. run_tests.sh takes NSPM_TEST_CONAN_CACHE / NSPM_TEST_BUILD_DIR to override its Docker volumes, and the container now prunes Conan's build and source trees after installing to keep the cache small. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run_tests.sh writes gtest JUnit XML to NSPM_TEST_RESULTS_DIR when it is set, and CI publishes it with action-junit-report. annotate_only is used because creating a check run needs checks: write, which pull requests from forks don't get. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ia players and the EntityManager In test builds, HomeAssistantManager, OpenhabManager and MQTT_Manager record what they send, so tests can check the exact service calls and MQTT messages without a live connection. HomeAssistantManager::test_process_event() delivers an event through the real observer routing. All of it is behind TEST_MODE, so production builds are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Room::_room_temp_provider was never initialised, so loading a room without a temperature sensor logged "Got unknown temperature provider ... Provider '<random number>'", and if the garbage happened to match a controller it detached an observer that was never attached. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The check was copied from HomeAssistantSwitch, so every NSPM button logged "HomeAssistantSwitch has not been recognized as controlled by HOME_ASSISTANT" when it was loaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a TEST_MODE hook that feeds a raw websocket message through OpenhabManager's parser, and one that points the REST API at an address without opening a websocket. Tests use a small fake HTTP server on 127.0.0.1 for the REST calls (initial item state, rule runs). Known bugs are pinned as DISABLED_ tests: - Thermostat reads openhab_preset_item/openhab_swing_item/ openhab_swingh_item but Django writes *_mode_item, so presets and swing are sent to openhab/items//command and never followed. - The current temperature item is never attached, and its callback checks the target temperature item. - The initial target temperature fetched over REST is rounded. - OpenhabScene::activate passes a dangling c_str() as the Authorization header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover the NSPanelConfig the manager publishes (defaults, panel settings, temperature calibration, screensaver fallbacks, button modes, thermostat limits, relay group bindings, room infos and locking, reload, unknown room, denied panel) and the command paths (reboot topics, MQTT payload buttons, detached buttons toggling entities and activating scenes). Adds TEST_MODE hooks to count MQTT and CommandManager callbacks. Pinned as DISABLED_ (KNOWN BUG): - a panel that is neither accepted nor denied reports WAITING, not AWAITING_ACCEPT - ~NSPanel leaves its CommandManager callback and log/legacy status subscriptions attached Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d8220f6 moved the thermostat temperature limits in NSPanel::send_config() and dropped the line that sets inside_temperature_sensor_mqtt_topic along with them. Since then panels always show their built-in sensor on the screensaver, even when their room has a temperature sensor configured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The thermostat limits are free text in the web interface, and send_config() parsed them with std::stoi. d8220f6 stopped empty values being saved, but any other non-numeric value still threw std::invalid_argument, which reload_config() does not catch (it only catches std::system_error). Parse the limits safely, logging an error and sending 0 for a bad value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- _humidity and _pressure were never initialised, so the web interface was sent whatever was in memory until the panel's first status report. Reset them with the other readings when the panel is offline. - _relay1_state, _relay2_state and _register_relay1/2_as_light were never initialised. Default them to false. - The relay 2 "Will register" debug log printed relay 1's setting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since NSPManager#391 the NSPanel constructor sets WAITING whenever the panel has a config topic, which pending panels have too, so it overwrote the AWAITING_ACCEPT state set by reload_config(). A newly discovered panel was reported to the web interface as "waiting" with "accepted": true. Only set WAITING for panels that are not awaiting accept, and drop the duplicated block that set OFFLINE just before it. Accepting a panel only worked because of that bug: register requests from panels in AWAITING_ACCEPT or DENIED are ignored, and nothing moved an accepted panel out of those states. reload_config() now moves a panel that has just been accepted to WAITING. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
~NSPanel() never detached its CommandManager callback, and reset_mqtt_topics() missed the nspanel/<mac>/log subscription and the legacy nspanel/<name>/status and status_report subscriptions, which were built inline and never stored. After a panel was deleted, the next button press from any panel, or the next message on one of those topics, called into the destroyed NSPanel. Keep those topics in members so they can be detached, which also moves the legacy subscriptions over when a panel is renamed instead of leaving the old name subscribed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
std::stoi stopped at the first character it couldn't read, so "21.5" was silently sent as 21 and "21abc" as 21, and the "not a whole number" error was only ever logged for values that weren't numbers at all. Parse the whole value instead: - not a number (including trailing text): error, send 0 - out of range: error, send 0 - a decimal: round to the nearest degree and log a warning ScopedErrorLog can now record warnings too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OpenhabThermostat ignores an OpenHAB event for an item unless at least a second has passed since the item last changed, but the _last_*_change timestamps it compares against were never initialised. When the memory held a large value, target temperature, mode, fan, preset or swing updates from OpenHAB were silently ignored for that thermostat. CI hit this in OpenhabThermostatTest.follows_target_temperature_and_mode_from_openhab. Set them to 0 in the constructor, as OpenhabSwitch and OpenhabLight do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The _openhab_group_*_item_state_changed_event_thread_running flags were never initialised. If one started out true, the thread that processes that group's events (brightness, colour temperature or RGB) was never started, so the light ignored those OpenHAB group events. Set them to false in the constructor, along with the matching group event timestamps (always written before they are read today, but uninitialised). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scene tests already check the nspanelmanager context a script gets when activate() is called. These cover the paths that call it: - tapping a scene or script on a panel's page (ToggleEntityFromEntitiesPage) sends the panel's room and the script's room, or only the panel's room for a global script, and still runs from an unknown panel without a triggering room - the triggering room follows the panel when it moves room, and a renamed room's new name is sent - a detached button runs a script with the full context, or turns on a scene Adds a TEST_MODE hook to hand EntityManager a panel command without starting EntityManager::init()'s threads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every room starts a detached thread that waits on the room's mutex and condition variable forever. Since the tests started creating rooms, the binary segfaults (exit code 139) after all tests have passed: exit destroys EntityManager's static room list while those threads are still waiting. The manager never exits this way, so leave the threads alone and exit with std::_Exit once the results are written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI has seen the test binary segfault part-way through a run, which only showed up as exit code 139 with the last buffered output missing. Make stdout line-buffered, and on a fatal signal print the running test and a demangled backtrace before re-raising. Link with -rdynamic so frames in the executable are named. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MQTT_Manager::publish() and clear_retain() wrote to _mqtt_retain_buffer, a std::unordered_map, without holding any lock. Room status threads, entity updates and panel reloads publish retained messages from different threads, so concurrent inserts and erases could corrupt the map during a rehash. The test binary crashed this way in CI: a room's status thread published its retained state while a panel was loading and clearing its Home Assistant discovery topics. Give the buffer its own mutex, and replay it from a copy on reconnect, since publish() writes to it. The reconnect also sent and erased the buffered messages without _mqtt_client_mutex, which publish() and clear_retain() hold when adding to that list; take it there too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
load_rooms() erased removed rooms from _rooms and sorted it without _rooms_mutex, and update_all_rooms_status() walked it without the lock, while get_room(), get_all_rooms() and the room command handlers lock it. A config reload could reallocate or reorder the list under a reader. Take the lock for the erase and the sort, and have the 'All rooms' thread work on a copy taken under the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cf329ca to
554e30c
Compare
|
Hi @tpanajott -- Here's another set of tests (and several fixes) for the MQTTManager side of things. It looks like a lot but it's 99% new test code, rather than changes to existing logic. As issues were found, I kept the fixes (and associated tests to defend them) isolated to individual commits, to keep things clearer -- that's partially why there are so many commits. You're obviously free to squash them on merge. |
tpanajott
left a comment
There was a problem hiding this comment.
Hey there. Sorry for the delay, it's just been a bit much in life and also these PRs are massive 😆 This looks pretty good but there are still some behaviours that I'd like to check though I'm not sure as to how to do that the best way. For example, requesting a specific brightness on the main page should behave differently depending on which lights are on, what their settings are and so on. Though, I suspect that might be better to do after this PR has landed.
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - name: Restore Conan packages |
There was a problem hiding this comment.
This is awesome. I'll need to look into doing this when doing the actual release builds as well.
| {MQTT_MANAGER_SETTING::OPENHAB_TOKEN, {"openhab_token", ""}}, | ||
| {MQTT_MANAGER_SETTING::OPENHAB_BRIGHTNESS_CHANNEL_MAX, {"openhab_brightness_channel_max", "255"}}, | ||
| {MQTT_MANAGER_SETTING::OPENHAB_BRIGHTNESS_CHANNEL_MIN, {"openhab_brightness_channel_min", "0"}}, | ||
| {MQTT_MANAGER_SETTING::OPENHAB_RGB_CHANNEL_NAME, {"openhab_rgb_channel_name", ""}}, |
There was a problem hiding this comment.
We can probably remove this and any reference to openhab_rgb_channel_name as it's a remnant of how we planned to do things in the beginning but it never worked out that way.
There was a problem hiding this comment.
Ack -- removed!
There was a problem hiding this comment.
This all probably works but it's pretty hard to read. Would this not be better handed over to a library?
There was a problem hiding this comment.
Yeah, good call out -- this was pretty sloppy. Looks like ix::HttpServer, which is in a library we're already using, can be used for this. I've cut over to it
It was left over from an early plan for OpenHAB RGB lights and nothing reads it. Drop it from the MQTTManager settings enum and key map, the Django settings view and the React settings store, rather than giving it a key map entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the hand-written socket and HTTP parsing code with ixwebsocket's HttpServer, which MQTTManager already depends on and uses for the Nextion image server. ix::HttpServer can't bind to port 0, so the fake tries ports from 18800 until one is free. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No worries -- I understand there's a lot to review here. No pressure on my side.
Agreed; as far as I'm aware, this doesn't regress anything, but fixes some targeted issues identified during tests. If we want to expand coverage into other use cases, that's probably best done as extra PRs on top. It'll be good to get future PRs defended with a baseline of tests/builds and iterate from there. |
This adds a harness for MQTTManager unit tests, runs them in CI, and uses it to cover the entity types, the EntityManager, OpenHAB and the config the manager sends to panels. The bugs the tests found are either fixed in their own commits or pinned as disabled tests.
Running the tests
The build runs in Docker (same
python:3.12.5-bookwormbase and compiler as the production image). The first run builds the Conan dependencies from source and takes about an hour; after that they are cached in thenspm-mqttmanager-conanvolume and a rebuild plus run takes a few minutes. Output is also written totests/last_run.log../compile_mqttmanager.sh --teststill works: it now sets the newNSPM_BUILD_TESTSCMake option instead ofsed-editingCMakeLists.txt.The harness
NSPM_BUILD_TESTSCMake option. It turns onTEST_MODE=1and buildstests/. The oldif (TEST_MODE==1)block compared a string and was always false, so test discovery never ran.tests/builds its ownnspm_mqttmanager_testsexecutable.test_helpers.hpphas scoped database rows (entities, scenes, panels) that remove themselves, and builders that writeentity_dataexactly as Django'srest.pydoes.Send capture,
TEST_MODEonly.MQTT_Manager,HomeAssistantManagerandOpenhabManagerrecord what they send, so tests check the exact MQTT messages and service calls without a live connection:HomeAssistantManager::test_process_event()delivers an event through the real observer routing.OpenhabManagergets hooks to feed a raw websocket message through its parser and to point its REST calls at an address without opening a websocket.tests/fake_http_server.hppanswers those REST calls from 127.0.0.1, using ixwebsocket'six::HttpServer(already used by the Nextion image server, so no new dependency).MQTT_ManagerandCommandManagercan report how many callbacks are attached, used to check that destroyed panels clean up.Production builds are unchanged.
The existing inline tests in the Config and Light libraries are linked in whole, so they register too.
CI in
.github/workflows/mqttmanager-tests.ymlruns on PRs and pushes tomain/develthat touchdocker/MQTTManager/.actions/cache, keyed onconanfile.pyand the test Dockerfile. It's saved even if the tests fail, so only the first run (or a dependency change) pays the one-hour build.action-junit-report. It usesannotate_only, because creating a check run needschecks: write, which PRs from forks don't get.What's covered
light_config_test.cppswitch_test.cpp,button_test.cpp,scene_test.cpp,thermostat_test.cpp,media_player_test.cppbutton_test.cppentity_manager_test.cppopenhab_test.cppnspanel_test.cppnspanel_test.cppcovers:Fixes
Each fix is its own commit, with tests:
home_assistant_thermostat.cppandopenhab_thermostat.cppeach ended with a copy of the Home Assistant light tests, which didn't compile underTEST_MODE. Removed. Production was unaffected.OPENHAB_RGB_CHANNEL_NAMEhad no entry in_setting_key_map, which the existing inline config test caught. The setting is a leftover that nothing reads, so it's removed from MQTTManager, the Django settings view and the React settings store.Room::_room_temp_providerwas never initialised, so every room without a temperature sensor logged "Got unknown temperature provider", and could detach an observer it had never attached.NSPMButtonconstructor checked for the Home Assistant controller (copied fromHomeAssistantSwitch), so every NSPM button logged "has not been recognized as controlled by HOME_ASSISTANT" when it loaded.inside_temperature_sensor_mqtt_topic. Since then, panels always show their built-in sensor on the screensaver, even when their room has a temperature sensor.std::stoi. A value likeabcthrew an exception thatreload_config()doesn't catch.std::stoialso silently truncated21.5to 21 and read21abcas 21. The limits are now parsed whole:"accepted": true. Accepting a panel had only worked because of this bug: register requests from panels awaiting accept or denied are ignored, and nothing moved an accepted panel out of those states.reload_config()now moves a newly accepted panel to WAITING.OpenhabThermostatignores an event if the item changed less than a second ago, but the_last_*_changetimestamps it compares against were never initialised. When that memory held a large value, OpenHAB updates for that item were silently ignored. CI hit this infollows_target_temperature_and_mode_from_openhab.OpenhabLight's_openhab_group_*_thread_runningflags were never initialised. If one started out true, the thread that processes that group's brightness, colour temperature or RGB events was never started.~NSPanel()didn't detach itsCommandManagercallback, itsnspanel/<mac>/logsubscription, or the legacynspanel/<name>/statusandstatus_reportsubscriptions. After a panel was deleted, the next button press from any panel, or the next message on one of those topics, called into the destroyed object. These topics are now stored and detached. Renaming a panel also moves its legacy subscriptions to the new name instead of leaving the old name subscribed.Known bugs, pinned as
DISABLED_testsEach has a
KNOWN BUGcomment. RemoveDISABLED_when fixing it.LightConfig.DISABLED_non_string_controller_does_not_throw: a light whosecontrollerisn't a string throwsnlohmann::json::type_errorinstead of falling back to Home Assistant.OpenhabSceneTest.DISABLED_activating_sends_the_token:OpenhabScene::activate()builds the Authorization header from.c_str()of a temporary, so the header is a dangling pointer.OpenhabThermostatTest.DISABLED_set_preset_and_swing_command_their_itemsandDISABLED_fetches_the_initial_preset_and_swing_over_rest: the thermostat readsopenhab_preset_item/openhab_swing_item/openhab_swingh_item, but Django stores*_mode_item. Presets and swing are sent toopenhab/items//commandand never follow OpenHAB. Once the names are fixed, the initial preset fetch compares against the HVAC mode, and the horizontal swing fetch checks the vertical item.OpenhabThermostatTest.DISABLED_publishes_the_current_temperature_from_openhab: the current temperature item is never attached, and its callback checks the target temperature item. Panels never show the thermostat's own reading.OpenhabThermostatTest.DISABLED_fetches_the_initial_target_temperature_over_rest: the initial target temperature fetched over REST is rounded to a whole degree, so 21.5 shows as 22.Notes
🤖 Generated with Claude Code