mirror of
https://github.com/meshtastic/firmware
synced 2026-08-07 20:32:18 -04:00
Some checks failed
CI / version (push) Failing after 5s
Daily Packaging / hook-copr (push) Has been cancelled
Daily Packaging / package-ppa (noble) (push) Has been cancelled
Daily Packaging / package-ppa (resolute) (push) Has been cancelled
Daily Packaging / package-ppa (stonking) (push) Has been cancelled
Daily Packaging / package-obs (push) Has been cancelled
Daily Packaging / docker-multiarch (push) Has been cancelled
Daily Packaging / package-ppa (jammy) (push) Has been cancelled
CI / setup (push) Has been cancelled
CI / build-debian-src (push) Has been cancelled
CI / MacOS (15) (push) Has been cancelled
CI / MacOS (26) (push) Has been cancelled
CI / Windows (2025) (push) Has been cancelled
CI / package-pio-deps-native-tft (push) Has been cancelled
CI / test-native (push) Has been cancelled
CI / build-wasm (push) Has been cancelled
CI / docker (alpine, native-tft, linux/arm64) (push) Has been cancelled
CI / docker (debian, native-tft, linux/arm64) (push) Has been cancelled
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
CI / ci-gate (push) Has been cancelled
CI / gather-artifacts (esp32) (push) Has been cancelled
CI / gather-artifacts (esp32c3) (push) Has been cancelled
CI / gather-artifacts (esp32c6) (push) Has been cancelled
CI / gather-artifacts (esp32s3) (push) Has been cancelled
CI / gather-artifacts (nrf52840) (push) Has been cancelled
CI / gather-artifacts (rp2040) (push) Has been cancelled
CI / gather-artifacts (rp2350) (push) Has been cancelled
CI / gather-artifacts (stm32) (push) Has been cancelled
CI / firmware-size-report (push) Has been cancelled
CI / size-budget-gate (push) Has been cancelled
CI / release-artifacts (push) Has been cancelled
CI / release-firmware (esp32) (push) Has been cancelled
CI / release-firmware (esp32c3) (push) Has been cancelled
CI / release-firmware (esp32c6) (push) Has been cancelled
CI / release-firmware (esp32s3) (push) Has been cancelled
CI / release-firmware (nrf52840) (push) Has been cancelled
CI / release-firmware (rp2040) (push) Has been cancelled
CI / release-firmware (rp2350) (push) Has been cancelled
CI / release-firmware (stm32) (push) Has been cancelled
CI / publish-firmware (push) Has been cancelled
CI / publish-nightly (push) Has been cancelled
* docs(nodedb): make the native node cap unambiguous The native node cap was stated in four places that disagreed, and the disagreement already caused a wrong diagnosis: a saturated 200-node database looked arithmetically impossible because the cap had been read as 248, computed from a header that does not apply on this platform. The real value is 198. On portduino MAX_NUM_NODES is not a compile-time constant at all - the variant defines it as `portduino_config.MaxNodes`, resolved at runtime, default 200 and settable per host with `General: MaxNodes`. variant.h is reached before mesh-pb-constants.h, so that header's ARCH_PORTDUINO branch never fires and its plausible-looking 250 is dead code. - #error-guard the dead branch rather than leave a wrong number where people grep. The guard found a real defect: seven translation units reach mesh-pb-constants.h without configuration.h (SerialConsole.cpp, StreamAPI.cpp, PacketAPI.cpp, ServerAPI.cpp, PiWebServer.cpp, ServiceEnvelope.cpp, MeshtasticOTA.cpp, and test/TestUtil.cpp), so each was compiling with a different MAX_NUM_NODES - and therefore a different PACKETHISTORY_MAX - than the rest of the build. Each now includes configuration.h first. It cannot be included from mesh-pb-constants.h itself: that reaches SerialConsole.h through DebugConfiguration.h and closes a cycle. - Name the bare 250 in getMaxNodesAllocatedSize() NODEDB_MIGRATION_LOAD_CEILING. It is a decode allowance for files written by larger-cap firmware, not a cap, and it read like one. - Fix docs/node_info_stores.md, which named the wrong source and a "10-250" range that is wrong for native, and the copilot-instructions tunables line that said "portduino 250". * test(harness): give each suite its own scratch HOME and report leftovers Native suites shared one directory. Every suite that constructs a NodeDB loads and saves ~/.portduino/default/prefs/ - nodes.proto, config.proto, channels.proto, module.proto, device.proto, warm.dat, transmit_history.dat - and nothing cleared it, so state leaked suite -> suite within a run and run -> every run after it. A test run could also rewrite a real meshtasticd node database on the same machine. Per-run isolation does not fix this: the leak is generated inside a single run, so the boundary has to be per suite. bin/pio-test-isolate.sh runs each suite in its own scratch $HOME, registered as test_testing_command for env:native and env:coverage so a bare `pio test` and CI get the same boundary, not just bin/run-tests.sh. It runs the binary unchanged and exits with its exit code, so PlatformIO's pass/fail is untouched. Overriding HOME here rather than around `pio` also sidesteps the blocker that a bare HOME= breaks pio's own ~/.platformio/penv/bin/pio lookup. Leftovers are reported as a second axis, PASS/FAIL x CLEAN/DIRTY, because an unintended write has no matching assertion by definition - nobody writes TEST_ASSERT for a save they do not know is happening. The harness asserts it from outside, so it applies to every suite without the author opting in. - Only the *set of changed paths* is asserted, never contents. Hashes answer the boolean "did this change?" and nothing more; content baselines over protobuf bytes would churn on every NodeInfoLite field added, which is how snapshot suites become noise. - Deliberate writes are declared in test/state-manifest.tsv - one central file, suite / flags / mandatory reason. run-tests.sh prints the opt-out count on every run. - Granularity follows the state flag, so the two ship together: per-test by default (TestUtil redefines RUN_TEST to checkpoint after each test, naming the exact test that dirtied things), suite boundary for state=per-suite, where carrying state across test cases is the declared behaviour. - A declared write that does NOT happen is reported as MISSING, not folded into DIRTY. It catches silently broken persistence; a warning for now, since some are conditional. - Graded AMBER, not RED. With isolation in place DIRTY means "undeclared", not "dangerous", and a check that lands red on day one gets switched off. Guard the guard, both halves: state_assert_empty() refuses to run a suite against a sandbox that is not empty (otherwise the after-diff measures against the wrong baseline and reports CLEAN while meaning nothing), and bin/test-state-check.sh drives the real wrapper with fixtures asserting CLEAN / CLEAN / DIRTY / MISSING plus both directions of the empty assertion. A checker that silently matches everything would otherwise pass forever. --write-manifest proposes entries for a human to paste and justify; it never applies them, and neither does CI. * test(harness): stop reporting Unity's exit code as a signal A native suite ends in exit(UNITY_END()), and UNITY_END() returns the failure count. PlatformIO's native runner reads that non-zero exit code as a POSIX signal number, so four failures print "Program received signal SIGILL", five print "SIGTRAP", and the suite is classified [ERRORED] rather than [FAILED]. There is no crash. The signal name tracks the failure count and nothing else - it moved SIGILL -> SIGTRAP when a diagnostic probe added a fifth failure - and it cost hours of hunting a memory bug that did not exist, on an env (native) that carries no sanitizer at all. It also explains the phantom extra test case in the totals: the runner adds a synthetic entry for the signal it thinks it saw. run-tests.sh now says so inline whenever a signal line appears, and the three agent-facing docs say it too. * test(admin): isolate NodeDB and globals per test setUp() did `if (!nodeDB) nodeDB = new NodeDB();` and never deleted it, so 83 of the 85 tests shared one never-reset database and never restored config, owner, devicestate or channelFile. The fixture that does restore them was opt-in and armed by exactly two tests. The setUp comment claiming the rest "set their own config/region state and are unaffected" was not true - the admin handlers under test write all four globals. Route every test through the fixture instead: setUp saves the globals and installs a fresh NodeDB, tearDown restores and deletes it. The two tests that armed it themselves no longer need to. All 85 pass, so nothing was silently relying on the shared state. It costs about 7% of the suite's runtime (a NodeDB construction is a loadFromDisk plus, with a region set, key generation) - worth paying to write the phase 3 tests against a clean fixture rather than 83 tests' residue. Also cap the per-test attribution in the run summary at five entries; the full list stays in the suite's sandbox. * test(fs): cover the bounded file-manifest walk getFiles() runs on every phone sync via STATE_SEND_FILEMANIFEST, and nothing asserted any of its bounding behaviour. It does execute unasserted from test_stream_api's handshakes, but the cap, the depth limit, the wasLimited paths, overlong-path rejection and capacity release were all unguarded. Eight tests, all describing what the code does today: today's code is already correct here, since #10778 landed the by-reference collectFiles(), the 64-entry cap, the strlcpy bounds and the swap-idiom release. They pass on arrival, which is the point - this is the baseline a later change has to leave alone. Two things they do not cover, and cannot: - Moving reserve() outside the __cpp_exceptions guard. Exceptions are on natively, so the #else branch is not compiled. The suite's job there is to prove that change alters nothing observable. - The file.name() null guard. No in-tree backend returns null; the guard is defensive. The manifest-release test pins the swap idiom rather than calling PhoneAPI's releaseFilesManifest(), which is file-local. It asserts capacity() == 0, not just size() == 0 - a size-only check passes on clear(), which is the bug #7924 shipped. Suite count 43 -> 44, recounted against the directories rather than copied. * test(admin): assert node-DB metadata saves skip the radio reload set_favorite_node, set_ignored_node and toggle_muted_node each persist a NodeInfoLite bit and nothing else. MeshService::reloadConfig() gates its region re-derivation and configChanged notification on saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS), so a SEGMENT_NODEDATABASE-only save already skips the live radio reconfigure. Pure characterization - all three pass on develop. Worth pinning because that reconfigure is the path implicated in the WisMesh Tag favourite-node crash, and develop asserts nothing about it: widening the saveWhat mask or reordering the check would currently go unnoticed. Ported from the config-save series along with ConfigChangedCounter (an Observer<void *> counting configChanged notifications, the only externally visible signal that the reload branch was taken) and TEST_NODE_NUM. They join the existing suite, so no suite-count change. * refactor(menu): extract the mute toggle into a named function The node menu's mute action was inline in a banner-callback lambda, and that lambda only ever runs via screen->showOverlayBanner() - which is why nothing in MenuHandler.cpp was reachable from a test. Lift the `selected == Mute` branch into menuHandler::toggleNodeMuted(uint32_t) and call it from the lambda. Behaviour-neutral by construction: same statements, same order, same bare saveToDisk(). The null check moves into the function, so the call site no longer needs its own lookup. Verified by the native build and suite; the byte-identical-image check on a headroom-constrained nRF52 board was not run locally - CI's firmware-size comment covers it. Three tests come with it, all describing today's behaviour: - the bit flips both ways and no configChanged fires (develop never calls reloadConfig on this path); - an unknown node is a no-op rather than a write; - and the segment mask. Flipping one NodeInfoLite bit currently rewrites all five segments via bare saveToDisk(). That is asserted deliberately, with the comment naming it as characterization of a known defect: a pending fix narrows it to SEGMENT_NODEDATABASE, and when it lands this assertion is expected to change, which makes the improvement visible in the diff instead of silent. saveToDisk() is not virtual, so the mask is observed through its effect - remove the five prefs files, toggle, and see which reappear. * docs(test): make every suite count a pointer to the canonical one test/native-suite-count is the registered total and is machine-checked against test/test_* on every full run and by the suite-count-check CI job. Every other statement of the count is a copy that drifts: copilot-instructions said 12, AGENTS.md said 19, and the real number is 44. Replace both literals with a pointer to the file, say explicitly that no document should state the count as a literal, and reframe the two suite listings as descriptions rather than inventories - they carry per-suite information the count does not, so they stay, but nothing should infer completeness from their length. Register the new FS suite in both. * test(harness): randomise suite order, reproducibly Landed last, deliberately. Randomising an order-dependent suite set does not find bugs so much as convert a silent pass into intermittent red, and the first instinct is to revert the randomisation rather than fix the coupling. Phases 1-2 removed the coupling; this keeps it removed. Both runners previously hid order dependence behind a fixed order that happened to differ between them, and neither order was chosen: CI's area rules put admin first, PlatformIO's local discovery is reverse alphabetical and put it last. CI was green by accident. - bin/run-tests.sh --shuffle / --seed <n>. The seed defaults to HEAD's short SHA: one order per commit, so a red is replayable and attributable to the diff instead of flaky, while the project keeps exploring orders. Printed at the start and carried into the RESULT line, so a verdict is replayable from that line alone; the full order is printed on failure, because for an order-dependent failure the order is the diagnostic. - The shuffle is a Fisher-Yates over a MINSTD generator rather than awk's rand(), whose sequence differs between gawk and mawk. A seed that does not reproduce the same order on another machine is not a seed. - Shuffling needs one `pio test -f <suite>` invocation per suite - PlatformIO orders by its own os.walk() over test/ and filters only select - which measures at about 4.7s per suite of extra startup. - CI shuffles its area order, seeded from GITHUB_SHA and printed with the command to replay it locally. Intra-area order stays PlatformIO's; controlling it there would mean per-suite invocations, which is a cost worth deciding separately. Also records the 16 measured entries in test/state-manifest.tsv, each with its reason, taken from a full run's --write-manifest output rather than guessed. * test(default): cover the region-throttle interval overload getConfiguredOrDefaultMsScaled(configured, default, nodes, TrafficType) is the overload every telemetry and position module actually calls, and nothing referenced TrafficType anywhere under test/. All four of its behaviours were unguarded: the no-region guard, the throttle <= 1 short-circuit, the multiply, and the 64-bit overflow clamp. The throttles are real, not hypothetical - EU_866 carries PROFILE_LITE, which sets both positionThrottle and telemetryThrottle to 10, so a change here moves broadcast spacing in that region by an order of magnitude. Each test pins numOnlineNodes at the congestion threshold and uses ROUTER, which never congestion-scales, so the coefficient is 1 and the throttle is the only variable. The overflow case needs a base above INT32_MAX/10, hence three days rather than one. * ci(test): keep pull-request suite order fixed, seed the rest Shuffling the area order on every run - including pull_request - would turn a contributor's PR red for an ordering they did not choose, which is how a randomisation gets reverted instead of the coupling being fixed. That is the exact dynamic the ordering work was sequenced last to avoid, and the previous commit walked straight into it. - pull_request keeps the fixed declared area order. - push and schedule shuffle, seeded from the commit SHA: deterministic per commit, printed, attributable, and never blocking someone else's PR. - A suite_order_seed input on workflow_call and workflow_dispatch overrides both, so a specific failing order can be replayed anywhere, including on a PR. The run log prints which mode it took, the resulting order, and the local command to replay it. * ci(test): satisfy CKV_GHA_7 and yamllint on the seed input The seed is reachable through workflow_call, which callers can pass programmatically. The workflow_dispatch copy tripped checkov's "workflow_dispatch inputs MUST be empty" rule, and suppressing it was not worth it: replaying a specific order is a local operation, and the run log already prints the exact bin/run-tests.sh command to do it. * style(menu): apply the node-ID format convention RadioInterface.cpp documents the rule: 0x%08x in logs, !%08x in user-facing display. MenuHandler held every remaining exception - seven logs printing bare %08X, and two display labels doing the same. Repo-wide there are now no bare %08X node IDs left in log calls. * ci(test): pass workflow inputs through env, not shell interpolation suite_order_seed and github.event_name were spliced into the run: script as ${{ }} text, so a value carrying shell metacharacters would execute as code on the runner rather than being read as data. semgrep (run-shell-injection) and zizmor (template-injection) both flag it. Both now arrive as environment variables and are read as "$VAR". * refactor(test): share the seeded shuffle between the harness and CI bin/run-tests.sh and test_native.yml each carried a byte-identical copy of the MINSTD Fisher-Yates awk. The workflow prints "replay locally: ./bin/run-tests.sh --shuffle --seed $seed" after a shuffled CI run, and that instruction is only true while the two agree - drift would be announced by a replay quietly reproducing a different order than the one that failed. Extract shuffle_suites() to bin/lib/shuffle.sh and source it from both. Permutations verified identical across seeds before and after the move. * fix(test): correct the shared-state MISSING check and summary join Three defects in the new harness: state_classify() matched declarations two different ways - state_path_declared() for "undeclared", a hand-rolled regex for "missing". Interpolating an entry into an ERE also let a metacharacter in a manifest name match a file that is not the declared one. Both directions now go through the one helper. `paste -sd'; '` does not join with "; ": with -s, paste cycles through a multi-character delimiter one character per join, so paths rendered as "a;b c;d e". Replaced with an awk join. test-state-check.sh ran on after a failed cd instead of stopping (SC2164). ./bin/test-state-check.sh: 6/6 fixtures pass, MISSING included. * fix(portduino): bound General.MaxNodes MaxNodes was validated only for <= 0. Any positive value, including a typo'd or pasted-in one, propagates to MAX_NUM_NODES and scales both the node DB and the nodes.proto decode ceiling - failing at boot with no obvious cause. The ceiling is a sanity bound, not a capability limit; raise it if a host genuinely needs more. * docs(nodedb): reconcile the capacity tables The property matrix omitted the ESP32-S3 100-node flash tier that the platform table above it lists, and neither mentioned that the WASM build overrides MaxNodes to 80 in wasm_config_apply(). * fix(nodedb): make mesh-pb-constants.h self-sufficient on portduino The ARCH_PORTDUINO #error assumed it was unreachable in a normal build. It is not: the vendored device-ui sources include this header without configuration.h, which broke both native-tft docker builds. Include configuration.h here instead, ahead of every compile-time default - variant.h overrides MAX_RX_TOPHONE as well as MAX_NUM_NODES, so placing it lower in the file just moves the divergence to a redefinition. The #error stays as a backstop for the case where that include genuinely stops providing the cap. Verified with the native env's own flags: a TU including only this header now compiles, normal-order use of both macros compiles, and NodeDB.cpp compiles. * fix(portduino): raise the MaxNodes ceiling to 16000 Marked artificial: nothing in the node DB fails at 16001. 16000 sits just under the 16384 (128 x 128) population where HopScalingModule saturates its sampling denominator and starts dropping nodes, so a host inside the bound still gets meaningful hop recommendations. * lint(trunk): advise on node IDs logged as bare %08x RadioInterface.cpp documents the convention - 0x%08x in logs, !%08x in display - but nothing enforced it, which is how the MenuHandler cluster drifted. 22 call sites in PacketHistory, NodeInfoModule and PositionModule are still off it. A trunk linter rather than a CI grep job, because trunk checks changed files: new violations get flagged without a 22-site cleanup landing in an unrelated PR. Modelled on the existing too-many-defined definition. Scoped to values it can tell are IDs - an ID-shaped argument (->num, .from, getNodeNum) or message text naming one. A 32-bit hex that is not an ID is out of scope, so the CRC32 logs in ethOTA.cpp are correctly ignored. Emits "note", trunk's only non-blocking level: "warning" and "info" both exit non-zero and would gate CI, which is not what a log-format nit deserves. The pre-existing sites are line-scoped in the allowlist, so a new bad call in those same files is still caught. * lint(trunk): stop exempting the known node-id-format sites The seeded allowlist made the rule green by declaring the backlog acceptable. Empty it instead, so the 22 pre-existing sites are reported and get cleaned up by whoever next edits those files. Costs nothing to do: the rule emits "note", so these are non-blocking either way. The allowlist stays for its real purpose - a value the linter misreads as an ID. * style: log node and packet IDs as 0x%08x Clears the 22 sites the node-id-format linter reports, so the rule starts from zero rather than from a backlog nobody can see - trunk suppresses pre-existing findings by default, so left alone these would not have surfaced on edit the way an empty allowlist implies. Format strings only; no argument or control flow changes. The !%08x user-facing display forms are deliberately untouched - that is the other half of the same convention. * test(harness): build once up front, so suite timings mean something run-tests.sh fused build and run in a single pio invocation, so whichever suite PlatformIO's directory walk reached first absorbed the entire src compile and reported it as its own duration. On a real run that made a 0.03s suite report 13m21s, and hid the build cost from every other number in the summary. Do what .github/workflows/test_native.yml already does: one --without-testing build pass, then run with --without-building. Measured on a full 44-suite run - the build is now a single reported figure and 968 test cases execute in 1.9s, with no suite above 0.084s. Build output goes to its own log rather than $LOG: the outcome regexes match "error:" and "[ERRORED]", so a compiler diagnostic sharing that file would read as a test failure. Both red paths now keep the log they quote from. $LOG and the build log are mktemps the EXIT trap removes, so the three grepped lines were previously all anyone ever saw - and the cause is usually further up than the first [FAILED]. * test(harness): keep the run log on every red path bin/pio-test-isolate.sh already keeps a failing or DIRTY suite's sandbox and log under .pio/test-state/<suite>/. What was missing is the cross-suite view: $LOG is a mktemp the EXIT trap deletes, so run-tests.sh quoted three grepped lines from a file that no longer existed by the time anyone looked. Preserve it as .pio/build/<env>/test-failure.log from both red paths - including "no success summary found", which said "see log" while preserving nothing, and which is exactly the case where the build died before any suite ran and so left no per-suite sandbox either. Cleared at the start of every run, so a green run cannot leave a red one's log lying around looking current. * fix(test): report the real failure count on a shuffled red A shuffled run is one `pio test` invocation per suite, all appending to the same log, so the log carries one PlatformIO "N test cases:" summary per suite. verdict_red() took `tail -1`, which reports whatever the LAST suite did: a failure in suite 3 printed a "0 failed" summary from suite 44 directly under "RED - failures detected:". Sum the summaries instead. A single summary line - every unshuffled run - is passed through verbatim, so the familiar output is byte-identical. The patterns are passed to the awk helper as strings rather than /regex/ literals: awk evaluates a regex literal in argument position as `$0 ~ /re/`, so the callee would receive 0 or 1 and silently sum garbage. * fix(test): do not emit an empty suite name for an empty shuffle `printf '%s\n' "$@"` with no arguments still writes one empty line, and both callers read shuffle_suites through mapfile, so an empty suite list arrived as a single suite named "". Return before the printf when there is nothing to shuffle. * test(harness): state and enforce the Linux host requirement The native harness is a Linux tool: bash 4+ (mapfile), GNU coreutils and GNU find (-printf, md5sum, -executable). Most of that predates this branch - mapfile and both find predicates are already on develop - but none of it was written down, so the requirement was there to be discovered rather than read. Refuse to start on a non-Linux uname instead of degrading. On a BSD userland this would not fail cleanly: it would mis-hash the sandbox and mis-read the suite list, and still print a verdict. A state check that silently measures the wrong thing is worse than one that declines to run. Carrying a per-host fallback was the alternative, and it buys a second code path that nothing in CI exercises. bin/test-native-docker.sh already exists for macOS and non-Linux hosts, and the native-macos PlatformIO env is a build target for meshtasticd, not a test host - the isolation wrapper is registered for env:native and env:coverage only. Documented in the script header, test/README.md, and both agent docs. * fix(test): terminate every suite with exit(UNITY_END()) Two sites across two suites ended on a bare UNITY_END(). That ends the reporting, not the suite: setup() returns, the runtime goes on calling loop(), and the process runs forever. PlatformIO does not notice - it reports a suite from its Unity output, not from process exit - so the suite passes, the run goes green, and the binary stays resident. Thirteen of them had accumulated on one dev box, the oldest 19 hours old. The costs are quiet by construction: - the per-suite sandbox is deleted underneath a live process, so its CLEAN/DIRTY verdict describes what the suite had written when the harness stopped looking, not what it left behind; - .gcda coverage and LeakSanitizer's report both flush from atexit handlers, so a suite that never exits contributes no coverage and gets no leak check; - each survivor pins its own deleted 94 MB binary, which du cannot see. One of the two is the #else of an architecture guard, which is the easiest one to get wrong - it looks like there is nothing to clean up. test_mqtt has a correct exit(UNITY_END()) in its live branch, so a "does this file call exit() anywhere" check passes the file whole. test_serial had two more. develop's serial-config validation rework restructured that suite - the architecture guard is gone and both remaining branches now exit correctly - so this commit no longer has anything to change there; bin/lint-unity-exit.sh, added later on this branch, is what keeps it that way. test/README.md gets a section on it, since the skeleton showing the right shape had not stopped this happening. * test(harness): detect and reap suites that outlive their run A suite that never exits was invisible: PlatformIO reports a suite from its Unity output, so the run stayed green while the binary kept running. Two checks, because they fail differently. Runtime, in bin/pio-test-isolate.sh: the sandbox $HOME is mktemp-unique per suite, so any process still holding it is a survivor of that suite. Matching on the environment rather than a remembered PID identifies one whatever its parentage - a fork, a grandchild, a process already reparented to init - none of which a $! comparison catches. Reaped before the after-fingerprint is taken, so that fingerprint measures a tree nobody is still writing to, and so a run cannot leave processes accumulating on the host. Recorded as a sixth summary column and graded AMBER: the tests did pass, but the CLEAN verdict and the coverage were measured under a false assumption. Author-time, as bin/lint-unity-exit.sh, wired into trunk at "note" like node-id-format: every UNITY_END() must be wrapped in exit(). The rule is per occurrence, and that is the point - a file-level "calls exit() somewhere" check passes test_serial and test_mqtt, which have a correct one in their live branch and a bare one in the #else. Running it over the tree turned up test_mqtt, which the file-level pass had missed. It allows `int rc = UNITY_END(); ...; exit(rc)`, used by test_packet_signing to restore globals between the summary and the exit. That is where the rule gives ground: capturing and never exiting would leak and is not flagged. Flagging a correct idiom would push someone to "fix" working code. bin/test-state-check.sh gains a survivor fixture, asserting the wrapper both reports and reaps - a detector that only reports leaves the host accumulating processes, which is half the harm. 8/8. * fix(lint): make the unity-exit scanner statement-aware The rule judged one physical line at a time, which reports two kinds of correct code as bare: /* a comment that happens to mention UNITY_END() */ <- interior lines were never stripped exit( UNITY_END()); <- exit( and the macro never met On a probe of both, two of three findings were wrong. This is a note-level rule whose whole job is advice, and bin/lint-node-id-format.sh already says why that matters: a false positive costs more than a miss. One that cries wolf gets ignored, and the real finding goes with it. Carry /* ... */ state across lines and accumulate logical statements before testing, with a 12-line cap so one unclosed call cannot swallow the rest of the file - the same structure lint-node-id-format.sh uses, so the two custom linters in bin/ work alike rather than each having its own idea. Verified both directions: the develop-era sources still produce the same four findings, the fixed tree produces none, and a probe covering block-comment interiors, wrapped exit(), line comments, return UNITY_END() and capture-then- exit reports only the genuinely bare calls - including a complete block comment followed by real bare code on the same line, which the state machine has to keep live. Reported by CodeRabbit on #11322. * fix(lint): tokenise instead of pattern-matching, and self-test it Second round of review findings on the same scanner, all confirmed by direct test before changing anything. Six defects, one root cause: layered regexes cannot tokenise C++. False positives (correct code reported): - UNITY_END() inside a string literal read as code False negatives (real leaks missed): - a string containing "/*" opened comment state and swallowed later lines - greedy .* removed everything between two block comments on one line, taking a bare call with it - myexit(UNITY_END()) matched the exit() exemption as a substring - x == UNITY_END() and total += UNITY_END() matched the assignment exemption Replaced with a character-level scan carrying comment state, and token-bounded exemptions: exit must be a whole identifier, and the capture form must be a plain `=`. Raw string literals are still not modelled - there are none under test/, and delimiter tracking for a case that does not occur would be untested code guarding untested code, so it is documented rather than guessed at. Also drops the `return UNITY_END()` exemption. It only terminates from main(), there is no main() under test/, and from a helper it just returns a count. bin/test-lint-unity-exit.sh pins all fifteen cases, every false positive and false negative found in review among them. The rule has been wrong twice in a way that looked fine by inspection; it needed a self-test more than it needed another careful reading. Two further findings in the same review: - bin/run-tests.sh dropped PASSTHRU in shuffled mode, so `--shuffle -vvv` built verbosely and then ran quietly. The shuffled loop now forwards EXTRA_ARGS, which is PASSTHRU minus the -f pair it supplies per suite. - bin/run-tests.sh did not guard `cd "$ROOT_DIR"`. And one that did not reproduce: the survivor fixture's glob does find the pid file (verified with the lookup instrumented - the earlier failure was an artifact of running the script from /tmp, where SCRIPT_DIR cannot resolve). The assertion was still weak, because an empty pid took the "not running" branch and passed vacuously. It now fails if the pid was never recorded, and finds the file by search rather than assuming a directory depth. Reported by CodeRabbit on #11322. * fix(lint): report each UNITY_END occurrence at its own location The self-test only asked "did the linter say anything", so it could not have caught a wrong line, a wrong column, or a missing second finding. Fixtures now assert the exact diagnostics as line:col, and the first run of that assertion found two real problems. The caret pointed at the wrong occurrence. For `exit(UNITY_END()); UNITY_END();` the verdict was right but the column was 17 - the wrapped call - because the scanner stripped terminating forms out of the whole statement and then reported the first occurrence it had seen. Two bare calls on one line reported once. Judged per occurrence now, by looking back through whitespace at what wraps it, so both the count and the caret are right. That also needed a position map from strip_noncode(): removing a comment or collapsing a literal shifts every later column, and counting occurrences in the raw line does not recover it either - TEST_MESSAGE("... UNITY_END() ..."); UNITY_END(); has two occurrences in the raw text and one in the code. Four of the expected columns I wrote by hand were also wrong, off by one. The linter was right in every case; the assertions were not. They are computed from the fixture text now rather than pasted from output, because a baseline accepted from the tool it is testing asserts nothing. 17 fixtures, including the two-on-one-line case from review and its mirror. Reported by CodeRabbit on #11322.
2008 lines
85 KiB
C++
2008 lines
85 KiB
C++
/**
|
|
* Tests for the radio configuration validation and clamping functions
|
|
* introduced in the radio_interface_cherrypick branch.
|
|
*
|
|
* Targets:
|
|
* 1. getRegion()
|
|
* 2. RadioInterface::validateConfigRegion()
|
|
* 3. RadioInterface::validateConfigLora()
|
|
* 4. RadioInterface::clampConfigLora()
|
|
* 5. RegionInfo preset lists (PRESETS_STD, PRESETS_EU_868, PRESETS_UNDEF)
|
|
* 6. Channel spacing calculation (placeholder for future protobuf changes)
|
|
*/
|
|
|
|
#include "Channels.h"
|
|
#include "DisplayFormatters.h"
|
|
#include "FSCommon.h"
|
|
#include "MeshRadio.h"
|
|
#include "MeshService.h"
|
|
#include "NodeDB.h"
|
|
#include "RadioInterface.h"
|
|
#include "TestUtil.h"
|
|
#include "graphics/draw/MenuHandler.h"
|
|
#include "mesh/Channels.h"
|
|
#include "modules/AdminModule.h"
|
|
#include "modules/NodeInfoModule.h"
|
|
#include <pb_decode.h>
|
|
#include <pb_encode.h>
|
|
#include <string>
|
|
#include <unity.h>
|
|
#include <vector>
|
|
|
|
#include "meshtastic/config.pb.h"
|
|
#include "support/AdminModuleTestShim.h"
|
|
|
|
// hash() is a file-scope function in RadioInterface.cpp; link it in for slot-formula tests
|
|
extern uint32_t hash(const char *str);
|
|
|
|
// Every client notification the AdminModule emits flows through sendClientNotification();
|
|
// capture each formatted message so the warning/coalescing tests can assert on the exact
|
|
// set of messages produced by a sequence of admin messages. This shadows test/support/MockMeshService.h's
|
|
// release-only stub because these tests need to inspect the captured message text, not just avoid leaks.
|
|
static std::vector<std::string> capturedWarnings;
|
|
|
|
class MockMeshService : public MeshService
|
|
{
|
|
public:
|
|
void sendClientNotification(meshtastic_ClientNotification *n) override
|
|
{
|
|
capturedWarnings.push_back(n->message);
|
|
releaseClientNotificationToPool(n);
|
|
}
|
|
};
|
|
|
|
static MockMeshService *mockMeshService;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// getRegion() tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
static void test_getRegion_returnsCorrectRegion_US()
|
|
{
|
|
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
TEST_ASSERT_NOT_NULL(r);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, r->code);
|
|
TEST_ASSERT_EQUAL_STRING("US", r->name);
|
|
}
|
|
|
|
static void test_getRegion_returnsCorrectRegion_EU868()
|
|
{
|
|
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
TEST_ASSERT_NOT_NULL(r);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, r->code);
|
|
TEST_ASSERT_EQUAL_STRING("EU_868", r->name);
|
|
}
|
|
|
|
static void test_getRegion_returnsCorrectRegion_LORA24()
|
|
{
|
|
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
|
|
TEST_ASSERT_NOT_NULL(r);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, r->code);
|
|
TEST_ASSERT_TRUE(r->wideLora);
|
|
}
|
|
|
|
static void test_getRegion_unsetCodeReturnsUnsetEntry()
|
|
{
|
|
const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET);
|
|
TEST_ASSERT_NOT_NULL(r);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
|
|
TEST_ASSERT_EQUAL_STRING("UNSET", r->name);
|
|
}
|
|
|
|
static void test_getRegion_unknownCodeFallsToUnset()
|
|
{
|
|
// A code not in the table should iterate to the UNSET sentinel
|
|
const RegionInfo *r = getRegion((meshtastic_Config_LoRaConfig_RegionCode)255);
|
|
TEST_ASSERT_NOT_NULL(r);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// validateConfigRegion() tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
static void test_validateConfigRegion_validRegionReturnsTrue()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
|
|
// Ensure owner is not licensed (should not matter for non-licensed-only regions)
|
|
devicestate.owner.is_licensed = false;
|
|
|
|
TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg));
|
|
}
|
|
|
|
static void test_validateConfigRegion_unsetRegionReturnsTrue()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
|
|
devicestate.owner.is_licensed = false;
|
|
|
|
// UNSET region has licensedOnly=false, so should pass
|
|
TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg));
|
|
}
|
|
|
|
static void test_validateConfigRegion_unknownCodeReturnsFalse()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)255;
|
|
|
|
devicestate.owner.is_licensed = false;
|
|
|
|
// Unknown code is not in the regions table; getRegion() returns the UNSET sentinel,
|
|
// whose .code != 255, so validateConfigRegion should reject it.
|
|
TEST_ASSERT_FALSE(RadioInterface::validateConfigRegion(cfg));
|
|
}
|
|
|
|
static void test_validateConfigRegion_anotherUnknownCodeReturnsFalse()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)99;
|
|
|
|
devicestate.owner.is_licensed = true;
|
|
|
|
// Unknown code should be rejected even when owner is licensed.
|
|
TEST_ASSERT_FALSE(RadioInterface::validateConfigRegion(cfg));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Shadow tables for testing (preset lists → profiles → regions → lookup)
|
|
// -----------------------------------------------------------------------
|
|
|
|
// A minimal preset list with only one entry
|
|
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_SINGLE[] = {
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
|
|
MODEM_PRESET_END,
|
|
};
|
|
|
|
// A preset list that includes all turbo variants only
|
|
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_TURBO_ONLY[] = {
|
|
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO,
|
|
MODEM_PRESET_END,
|
|
};
|
|
|
|
// A restricted list simulating a hypothetical tight-regulation region
|
|
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_RESTRICTED[] = {
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE,
|
|
MODEM_PRESET_END,
|
|
};
|
|
|
|
// Mirrors PROFILE_STD but with non-zero spacing/padding for testing
|
|
static const RegionProfile TEST_PROFILE_SPACED = {
|
|
TEST_PRESETS_SINGLE,
|
|
/* spacing */ 0.025f,
|
|
/* padding */ 0.010f,
|
|
/* audioPermitted */ true,
|
|
/* licensedOnly */ false,
|
|
/* textThrottle */ 0,
|
|
/* positionThrottle */ 0,
|
|
/* telemetryThrottle */ 0,
|
|
};
|
|
|
|
// A licensed-only profile for testing access control
|
|
static const RegionProfile TEST_PROFILE_LICENSED = {
|
|
TEST_PRESETS_RESTRICTED,
|
|
/* spacing */ 0.0f,
|
|
/* padding */ 0.0f,
|
|
/* audioPermitted */ false,
|
|
/* licensedOnly */ true,
|
|
/* textThrottle */ 5,
|
|
/* positionThrottle */ 10,
|
|
/* telemetryThrottle */ 10,
|
|
};
|
|
|
|
// Turbo-only profile
|
|
static const RegionProfile TEST_PROFILE_TURBO = {
|
|
TEST_PRESETS_TURBO_ONLY,
|
|
/* spacing */ 0.0f,
|
|
/* padding */ 0.0f,
|
|
/* audioPermitted */ true,
|
|
/* licensedOnly */ false,
|
|
/* textThrottle */ 0,
|
|
/* positionThrottle */ 0,
|
|
/* telemetryThrottle */ 0,
|
|
};
|
|
|
|
// A preset list for the preset-hash override slot test (LONG_FAST + MEDIUM_FAST)
|
|
static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_PRESET_HASH[] = {
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
|
|
MODEM_PRESET_END,
|
|
};
|
|
|
|
// Profile with overrideSlot = OVERRIDE_SLOT_PRESET_HASH (-1):
|
|
// slot selection always uses hash(presetDisplayName), ignoring the primary channel name.
|
|
static const RegionProfile TEST_PROFILE_PRESET_HASH = {
|
|
TEST_PRESETS_PRESET_HASH,
|
|
/* spacing */ 0.0f,
|
|
/* padding */ 0.0f,
|
|
/* audioPermitted */ true,
|
|
/* licensedOnly */ false,
|
|
/* textThrottle */ 0,
|
|
/* positionThrottle */ 0,
|
|
/* telemetryThrottle */ 0,
|
|
};
|
|
|
|
// Standalone test region using US frequencies (26 MHz span → 104 slots at 250 kHz BW)
|
|
// Used to verify OVERRIDE_SLOT_PRESET_HASH slot formula; not inserted into testRegions[].
|
|
static const RegionInfo TEST_REGION_PRESET_HASH = {
|
|
meshtastic_Config_LoRaConfig_RegionCode_US,
|
|
902.0f,
|
|
928.0f,
|
|
100,
|
|
30,
|
|
false,
|
|
false,
|
|
&TEST_PROFILE_PRESET_HASH,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST,
|
|
OVERRIDE_SLOT_PRESET_HASH,
|
|
"TEST_PRESET_HASH",
|
|
};
|
|
|
|
static const RegionInfo testRegions[] = {
|
|
// A wide US-like region with spacing + padding
|
|
{meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, 0, "TEST_US_SPACED"},
|
|
|
|
// A narrow band simulating tight EU regulation
|
|
{meshtastic_Config_LoRaConfig_RegionCode_EU_868, 869.4f, 869.65f, 10, 14, false, false, &TEST_PROFILE_LICENSED,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 3, "TEST_EU_LICENSED"},
|
|
|
|
// A wide-LoRa region with turbo-only presets
|
|
{meshtastic_Config_LoRaConfig_RegionCode_LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, &TEST_PROFILE_TURBO,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, 0, "TEST_LORA24_TURBO"},
|
|
|
|
// Sentinel - must be last
|
|
{meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, 0, "TEST_UNSET"},
|
|
};
|
|
|
|
static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode code)
|
|
{
|
|
const RegionInfo *r = testRegions;
|
|
while (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
|
if (r->code == code)
|
|
return r;
|
|
r++;
|
|
}
|
|
return r; // Returns the UNSET sentinel
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Shadow table tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
// Helper: replicate the numFreqSlots formula from RadioInterface so tests can compute expected values.
|
|
static uint32_t testComputeNumFreqSlots(const RegionInfo *r, float bw_kHz)
|
|
{
|
|
float w = r->profile->spacing + (r->profile->padding * 2) + (bw_kHz / 1000.0f);
|
|
return (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / w) + 0.5f);
|
|
}
|
|
|
|
static void test_shadowTable_spacedProfileHasNonZeroSpacing()
|
|
{
|
|
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
TEST_ASSERT_EQUAL_STRING("TEST_US_SPACED", r->name);
|
|
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.025f, r->profile->spacing);
|
|
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.010f, r->profile->padding);
|
|
}
|
|
|
|
static void test_shadowTable_licensedProfileFlagsCorrect()
|
|
{
|
|
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
TEST_ASSERT_TRUE(r->profile->licensedOnly);
|
|
TEST_ASSERT_FALSE(r->profile->audioPermitted);
|
|
TEST_ASSERT_EQUAL(3, r->overrideSlot);
|
|
}
|
|
|
|
static void test_shadowTable_presetCountMatchesExpected()
|
|
{
|
|
const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
TEST_ASSERT_EQUAL(1, spaced->getNumPresets());
|
|
|
|
const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
TEST_ASSERT_EQUAL(2, licensed->getNumPresets());
|
|
|
|
const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
|
|
TEST_ASSERT_EQUAL(2, turbo->getNumPresets());
|
|
}
|
|
|
|
static void test_shadowTable_defaultPresetIsFirstInList()
|
|
{
|
|
const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, spaced->getDefaultPreset());
|
|
|
|
const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, licensed->getDefaultPreset());
|
|
|
|
const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, turbo->getDefaultPreset());
|
|
}
|
|
|
|
static void test_shadowTable_channelSpacingWithPadding()
|
|
{
|
|
// Verify channel count when spacing + padding are non-zero
|
|
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora);
|
|
float channelSpacing = r->profile->spacing + (r->profile->padding * 2) + (bw / 1000.0f);
|
|
|
|
// spacing=0.025, padding=0.010*2=0.020, bw=250kHz=0.250
|
|
// channelSpacing = 0.025 + 0.020 + 0.250 = 0.295 MHz
|
|
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.295f, channelSpacing);
|
|
|
|
uint32_t numChannels = (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / channelSpacing) + 0.5f);
|
|
// (928 - 902 + 0.025) / 0.295 = 88.2 → 88
|
|
TEST_ASSERT_EQUAL_UINT32(88, numChannels);
|
|
}
|
|
|
|
static void test_shadowTable_turboOnlyOnWideLora()
|
|
{
|
|
const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
|
|
TEST_ASSERT_TRUE(r->wideLora);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, r->getDefaultPreset());
|
|
|
|
// Verify wide-LoRa bandwidth for SHORT_TURBO
|
|
float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora);
|
|
TEST_ASSERT_FLOAT_WITHIN(0.1f, 1625.0f, bw); // 1625 kHz in wide mode
|
|
}
|
|
|
|
static void test_shadowTable_unknownCodeFallsToSentinel()
|
|
{
|
|
const RegionInfo *r = getTestRegion((meshtastic_Config_LoRaConfig_RegionCode)200);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code);
|
|
TEST_ASSERT_EQUAL_STRING("TEST_UNSET", r->name);
|
|
}
|
|
|
|
static void test_shadowTable_presetHashProfileHasCorrectOverrideSlot()
|
|
{
|
|
TEST_ASSERT_EQUAL(OVERRIDE_SLOT_PRESET_HASH, TEST_REGION_PRESET_HASH.overrideSlot);
|
|
TEST_ASSERT_EQUAL(-1, TEST_REGION_PRESET_HASH.overrideSlot);
|
|
TEST_ASSERT_EQUAL(2, TEST_REGION_PRESET_HASH.getNumPresets());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// OVERRIDE_SLOT_PRESET_HASH (-1) slot formula tests
|
|
//
|
|
// Property under test:
|
|
// overrideSlot = -1 → slot = hash(presetDisplayName) % numSlots
|
|
// regardless of what the primary channel is named
|
|
// overrideSlot = 0 → slot = hash(channelName) % numSlots
|
|
// when channel name = preset display name, these two modes give identical slots
|
|
// -----------------------------------------------------------------------
|
|
|
|
static void test_overrideSlotPresetHash_longFast_customChannelMatchesDefaultNameSlot()
|
|
{
|
|
// US + LONG_FAST: spacing=0, padding=0, bw=250 kHz
|
|
// numSlots = round((928-902+0)/0.250) = 104
|
|
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora);
|
|
uint32_t numSlots = testComputeNumFreqSlots(us, bw);
|
|
TEST_ASSERT_EQUAL_UINT32(104, numSlots); // sanity
|
|
|
|
const char *presetName =
|
|
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true);
|
|
|
|
// OVERRIDE_SLOT_PRESET_HASH (-1):
|
|
// channel is "MyCustomNetwork" but slot still uses preset name hash
|
|
uint32_t slotPresetHashMode = hash(presetName) % numSlots;
|
|
|
|
// OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH (0) with channel name = preset name (user never renamed it):
|
|
// channelName == presetName → same hash → same slot
|
|
const char *defaultChannelName = presetName;
|
|
uint32_t slotChannelHashModeDefaultName = hash(defaultChannelName) % numSlots;
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(slotPresetHashMode, slotChannelHashModeDefaultName);
|
|
|
|
// Confirm a different custom channel name gives a different hash INPUT
|
|
// (so mode 0 would diverge while mode -1 stays locked)
|
|
TEST_ASSERT_TRUE(strcmp(presetName, "MyCustomNetwork") != 0);
|
|
}
|
|
|
|
static void test_overrideSlotPresetHash_mediumFast_customChannelMatchesDefaultNameSlot()
|
|
{
|
|
// US + MEDIUM_FAST: bw=250 kHz → same 104 slots as LONG_FAST for US
|
|
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, us->wideLora);
|
|
uint32_t numSlots = testComputeNumFreqSlots(us, bw);
|
|
TEST_ASSERT_EQUAL_UINT32(104, numSlots); // sanity
|
|
|
|
const char *presetName =
|
|
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true);
|
|
|
|
// Mode -1: slot = hash(presetName) % numSlots (channel name irrelevant)
|
|
uint32_t slotPresetHashMode = hash(presetName) % numSlots;
|
|
|
|
// Mode 0 + default name (channel name = preset display name):
|
|
uint32_t slotChannelHashModeDefaultName = hash(presetName) % numSlots;
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(slotPresetHashMode, slotChannelHashModeDefaultName);
|
|
|
|
TEST_ASSERT_TRUE(strcmp(presetName, "MyCustomNetwork") != 0);
|
|
}
|
|
|
|
static void test_overrideSlotPresetHash_longFast_slotIsStableAcrossCustomNames()
|
|
{
|
|
// Mode -1 must give the same slot for LONG_FAST regardless of which custom name is in use.
|
|
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora);
|
|
uint32_t numSlots = testComputeNumFreqSlots(us, bw);
|
|
|
|
const char *presetName =
|
|
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true);
|
|
uint32_t expectedSlot = hash(presetName) % numSlots;
|
|
|
|
// Simulate three different custom channel names; mode -1 ignores all of them
|
|
const char *customNames[] = {"AlphaNet", "BetaMesh", "GammaMesh"};
|
|
for (int i = 0; i < 3; i++) {
|
|
uint32_t slotForCustom = hash(presetName) % numSlots; // mode -1: presetName only
|
|
TEST_ASSERT_EQUAL_UINT32(expectedSlot, slotForCustom);
|
|
// Confirm input would have differed in mode 0
|
|
TEST_ASSERT_TRUE(strcmp(presetName, customNames[i]) != 0);
|
|
}
|
|
}
|
|
|
|
static void test_overrideSlotPresetHash_mediumFast_slotIsStableAcrossCustomNames()
|
|
{
|
|
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, us->wideLora);
|
|
uint32_t numSlots = testComputeNumFreqSlots(us, bw);
|
|
|
|
const char *presetName =
|
|
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true);
|
|
uint32_t expectedSlot = hash(presetName) % numSlots;
|
|
|
|
const char *customNames[] = {"AlphaNet", "BetaMesh", "GammaMesh"};
|
|
for (int i = 0; i < 3; i++) {
|
|
uint32_t slotForCustom = hash(presetName) % numSlots; // mode -1: presetName only
|
|
TEST_ASSERT_EQUAL_UINT32(expectedSlot, slotForCustom);
|
|
TEST_ASSERT_TRUE(strcmp(presetName, customNames[i]) != 0);
|
|
}
|
|
}
|
|
|
|
static void test_overrideSlotPresetHash_longFastAndMediumFast_slotsAreDifferentPresets()
|
|
{
|
|
// LONG_FAST and MEDIUM_FAST have different display names → likely different hash slots.
|
|
// This verifies the two presets genuinely occupy distinct positions, so the equivalence
|
|
// tests above are not trivially vacuous.
|
|
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
float bw_lf = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false);
|
|
float bw_mf = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false);
|
|
uint32_t numSlots_lf = testComputeNumFreqSlots(us, bw_lf);
|
|
uint32_t numSlots_mf = testComputeNumFreqSlots(us, bw_mf);
|
|
|
|
const char *nameLF =
|
|
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true);
|
|
const char *nameMF =
|
|
DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true);
|
|
|
|
TEST_ASSERT_TRUE(strcmp(nameLF, nameMF) != 0);
|
|
|
|
uint32_t slotLF = hash(nameLF) % numSlots_lf;
|
|
uint32_t slotMF = hash(nameMF) % numSlots_mf;
|
|
// They use the same numSlots (both 250 kHz on US), so a difference in display name
|
|
// should produce a different slot.
|
|
TEST_ASSERT_NOT_EQUAL(slotLF, slotMF);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// validateConfigLora() tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
static void test_validateConfigLora_validPresetForUS()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
|
|
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
|
|
}
|
|
|
|
static void test_validateConfigLora_allStdPresetsValidForUS()
|
|
{
|
|
meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = {
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO,
|
|
};
|
|
|
|
for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) {
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = stdPresets[i];
|
|
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for US");
|
|
}
|
|
}
|
|
|
|
static void test_validateConfigLora_turboPresetsInvalidForEU868()
|
|
{
|
|
// EU_868 has PRESETS_EU_868 which excludes the 500 kHz turbo presets
|
|
// (SHORT_TURBO, LONG_TURBO, MEDIUM_TURBO)
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
cfg.use_preset = true;
|
|
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
|
|
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for EU_868");
|
|
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO;
|
|
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_TURBO should be invalid for EU_868");
|
|
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
|
|
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_TURBO should be invalid for EU_868");
|
|
}
|
|
|
|
static void test_validateConfigLora_validPresetsForEU868()
|
|
{
|
|
meshtastic_Config_LoRaConfig_ModemPreset eu868Presets[] = {
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE,
|
|
};
|
|
|
|
for (size_t i = 0; i < sizeof(eu868Presets) / sizeof(eu868Presets[0]); i++) {
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = eu868Presets[i];
|
|
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for EU_868");
|
|
}
|
|
}
|
|
|
|
static void test_validateConfigLora_customBandwidthTooWideForEU868()
|
|
{
|
|
// EU_868 spans 869.4 - 869.65 = 0.25 MHz = 250 kHz
|
|
// A 500 kHz custom BW should be rejected
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
cfg.use_preset = false;
|
|
cfg.bandwidth = 500;
|
|
cfg.spread_factor = 11;
|
|
cfg.coding_rate = 5;
|
|
|
|
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
|
|
}
|
|
|
|
static void test_validateConfigLora_customBandwidthFitsUS()
|
|
{
|
|
// US spans 902 - 928 = 26 MHz, so 250 kHz BW fits easily
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
cfg.use_preset = false;
|
|
cfg.bandwidth = 250;
|
|
cfg.spread_factor = 11;
|
|
cfg.coding_rate = 5;
|
|
|
|
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
|
|
}
|
|
|
|
static void test_validateConfigLora_customBandwidthFitsEU868()
|
|
{
|
|
// EU_868 spans 250 kHz, 125 kHz BW should fit
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
cfg.use_preset = false;
|
|
cfg.bandwidth = 125;
|
|
cfg.spread_factor = 12;
|
|
cfg.coding_rate = 8;
|
|
|
|
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
|
|
}
|
|
|
|
static void test_validateConfigLora_bogusPresetRejected()
|
|
{
|
|
// A fabricated preset value not in any list should be rejected
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
|
|
|
|
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
|
|
}
|
|
|
|
static void test_validateConfigLora_unsetRegionOnlyAcceptsLongFast()
|
|
{
|
|
// UNSET uses PROFILE_UNDEF which has only LONG_FAST
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
cfg.use_preset = true;
|
|
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_FAST should be valid for UNSET");
|
|
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
|
|
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_FAST should be invalid for UNSET");
|
|
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
|
|
TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for UNSET");
|
|
}
|
|
|
|
static void test_validateConfigLora_allPresetsValidForLORA24()
|
|
{
|
|
// LORA_24 uses PROFILE_STD (10 presets) with wideLora=true
|
|
meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = {
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO,
|
|
};
|
|
|
|
for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) {
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = stdPresets[i];
|
|
TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for LORA_24");
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// clampConfigLora() tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
static void test_clampConfigLora_invalidPresetClampedToDefault()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; // not in EU_868 preset list
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), cfg.modem_preset);
|
|
}
|
|
|
|
static void test_clampConfigLora_validPresetUnchanged()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
|
|
}
|
|
|
|
static void test_clampConfigLora_customBwTooWideClampedToDefaultBw()
|
|
{
|
|
// EU_868 span is 250kHz. A 500kHz custom BW should be clamped to default preset BW.
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
cfg.use_preset = false;
|
|
cfg.bandwidth = 500;
|
|
cfg.spread_factor = 11;
|
|
cfg.coding_rate = 5;
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
float expectedBw = modemPresetToBwKHz(eu868->getDefaultPreset(), eu868->wideLora);
|
|
TEST_ASSERT_FLOAT_WITHIN(0.01f, expectedBw, (float)cfg.bandwidth);
|
|
}
|
|
|
|
static void test_clampConfigLora_customBwValidLeftUnchanged()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
cfg.use_preset = false;
|
|
cfg.bandwidth = 125;
|
|
cfg.spread_factor = 12;
|
|
cfg.coding_rate = 8;
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
TEST_ASSERT_EQUAL_UINT16(125, cfg.bandwidth);
|
|
}
|
|
|
|
static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast()
|
|
{
|
|
// UNSET uses PROFILE_UNDEF with only LONG_FAST; any other preset should clamp to it
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
|
|
}
|
|
|
|
static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault()
|
|
{
|
|
// LORA_24 uses PROFILE_STD; a bogus preset should clamp to LONG_FAST (first in PRESETS_STD)
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99;
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
const RegionInfo *lora24 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24);
|
|
TEST_ASSERT_EQUAL(lora24->getDefaultPreset(), cfg.modem_preset);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Region-locked preset swap tests (EU_868 / EU_866 / EU_N_868 trio)
|
|
// -----------------------------------------------------------------------
|
|
|
|
static void test_clampConfigLora_narrowPresetOnEU866SwapsToEUN868()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST;
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, cfg.region);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST, cfg.modem_preset);
|
|
}
|
|
|
|
static void test_clampConfigLora_litePresetOnEU868SwapsToEU866()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW;
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_866, cfg.region);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW, cfg.modem_preset);
|
|
}
|
|
|
|
static void test_clampConfigLora_eu868PresetOnEUN868SwapsToEU868()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_N_868;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, cfg.region);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
|
|
}
|
|
|
|
static void test_clampConfigLora_litePresetOnUSDoesNotSwap()
|
|
{
|
|
// Previous region is not one of the swappable trio, so the preset clamps to the
|
|
// region default instead of swapping regions.
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST;
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, cfg.region);
|
|
TEST_ASSERT_EQUAL(us->getDefaultPreset(), cfg.modem_preset);
|
|
}
|
|
|
|
static void test_clampConfigLora_narrowPresetOnHam125cmDoesNotSwap()
|
|
{
|
|
// ITU2_125CM shares the NARROW presets, so they are valid there and nothing changes
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW;
|
|
|
|
RadioInterface::clampConfigLora(cfg);
|
|
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM, cfg.region);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW, cfg.modem_preset);
|
|
}
|
|
|
|
static void test_validateConfigLora_siblingLockedPresetStillFailsValidation()
|
|
{
|
|
// Validation (no clamp) must keep failing so callers route into clampConfigLora,
|
|
// which performs the region swap.
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
|
|
cfg.use_preset = true;
|
|
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST;
|
|
|
|
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// RegionInfo preset list integrity tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
static void test_presetsStd_hasTenEntries()
|
|
{
|
|
// PROFILE_STD should have exactly 10 presets (adds MEDIUM_TURBO to the turbo cluster)
|
|
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
TEST_ASSERT_EQUAL(10, us->getNumPresets());
|
|
TEST_ASSERT_EQUAL_PTR(PROFILE_STD.presets, us->getAvailablePresets());
|
|
}
|
|
|
|
static void test_presetsEU868_hasSevenEntries()
|
|
{
|
|
const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
TEST_ASSERT_EQUAL(7, eu->getNumPresets());
|
|
TEST_ASSERT_EQUAL_PTR(PROFILE_EU868.presets, eu->getAvailablePresets());
|
|
}
|
|
|
|
static void test_presetsUndef_hasOneEntry()
|
|
{
|
|
const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET);
|
|
TEST_ASSERT_EQUAL(1, unset->getNumPresets());
|
|
TEST_ASSERT_EQUAL_PTR(PROFILE_UNDEF.presets, unset->getAvailablePresets());
|
|
}
|
|
|
|
static void test_defaultPresetIsInAvailablePresets()
|
|
{
|
|
// For every region, the defaultPreset must appear in its own availablePresets list
|
|
const RegionInfo *r = regions;
|
|
while (true) {
|
|
bool found = false;
|
|
for (size_t i = 0; i < r->getNumPresets(); i++) {
|
|
if (r->getAvailablePresets()[i] == r->getDefaultPreset()) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
char msg[80];
|
|
snprintf(msg, sizeof(msg), "Region %s defaultPreset not in availablePresets", r->name);
|
|
TEST_ASSERT_TRUE_MESSAGE(found, msg);
|
|
|
|
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
|
|
break; // UNSET is the sentinel, stop after it
|
|
r++;
|
|
}
|
|
}
|
|
|
|
static void test_regionFieldsAreSane()
|
|
{
|
|
// Basic sanity check: all regions have freqEnd > freqStart and a non-null name
|
|
const RegionInfo *r = regions;
|
|
while (true) {
|
|
char msg[80];
|
|
snprintf(msg, sizeof(msg), "Region %s: freqEnd must be > freqStart", r->name);
|
|
TEST_ASSERT_TRUE_MESSAGE(r->freqEnd > r->freqStart, msg);
|
|
TEST_ASSERT_NOT_NULL(r->name);
|
|
TEST_ASSERT_TRUE_MESSAGE(r->getNumPresets() > 0, "numPresets must be > 0");
|
|
TEST_ASSERT_NOT_NULL(r->getAvailablePresets());
|
|
|
|
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
|
|
break;
|
|
r++;
|
|
}
|
|
}
|
|
|
|
static void test_onlyLORA24HasWideLora()
|
|
{
|
|
// Verify that LORA_24 is the only region with wideLora=true
|
|
const RegionInfo *r = regions;
|
|
while (true) {
|
|
char msg[80];
|
|
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) {
|
|
snprintf(msg, sizeof(msg), "Region %s should have wideLora=true", r->name);
|
|
TEST_ASSERT_TRUE_MESSAGE(r->wideLora, msg);
|
|
} else {
|
|
snprintf(msg, sizeof(msg), "Region %s should have wideLora=false", r->name);
|
|
TEST_ASSERT_FALSE_MESSAGE(r->wideLora, msg);
|
|
}
|
|
|
|
if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
|
|
break;
|
|
r++;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Channel spacing calculation (placeholder for future protobuf updates)
|
|
// -----------------------------------------------------------------------
|
|
|
|
static void test_channelSpacingCalculation_US_LONG_FAST()
|
|
{
|
|
// Current formula: channelSpacing = spacing + (padding * 2) + (bw / 1000)
|
|
// US: spacing=0, padding=0
|
|
// LONG_FAST on non-wide region: bw=250 kHz
|
|
// channelSpacing = 0 + 0 + 0.250 = 0.250 MHz
|
|
// numChannels = round((928 - 902 + 0) / 0.250) = round(104) = 104
|
|
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
|
|
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora);
|
|
float channelSpacing = us->profile->spacing + (us->profile->padding * 2) + (bw / 1000.0f);
|
|
uint32_t numChannels = (uint32_t)(((us->freqEnd - us->freqStart + us->profile->spacing) / channelSpacing) + 0.5f);
|
|
|
|
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing);
|
|
TEST_ASSERT_EQUAL_UINT32(104, numChannels);
|
|
}
|
|
|
|
static void test_channelSpacingCalculation_EU868_LONG_FAST()
|
|
{
|
|
// EU_868: freqStart=869.4, freqEnd=869.65, spacing=0, padding=0
|
|
// LONG_FAST: bw=250 kHz => channelSpacing = 0.250 MHz
|
|
// numChannels = round((0.25 + 0) / 0.250) = 1
|
|
const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, eu->wideLora);
|
|
float channelSpacing = eu->profile->spacing + (eu->profile->padding * 2) + (bw / 1000.0f);
|
|
uint32_t numChannels = (uint32_t)(((eu->freqEnd - eu->freqStart + eu->profile->spacing) / channelSpacing) + 0.5f);
|
|
|
|
TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing);
|
|
TEST_ASSERT_EQUAL_UINT32(1, numChannels);
|
|
}
|
|
|
|
// Placeholder: when protobuf region definitions include non-zero padding/spacing,
|
|
// add tests here to verify the channel count and frequency calculations.
|
|
static void test_channelSpacingCalculation_placeholder()
|
|
{
|
|
// TODO: Once protobuf RegionInfo entries have non-zero padding or spacing values,
|
|
// verify:
|
|
// - Channel count matches expected value for each (region, preset) pair
|
|
// - First channel frequency = freqStart + (bw/2000) + padding
|
|
// - Nth channel frequency = first + (n * channelSpacing)
|
|
// - overrideSlot, when non-zero, forces the channel_num
|
|
TEST_PASS_MESSAGE("Placeholder for future channel spacing tests with updated protobuf region fields");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// handleSetConfig fromOthers dispatch tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
// AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares.
|
|
static AdminModuleTestShim *testAdmin;
|
|
static NodeDB *savedNodeDB;
|
|
static NodeDB *replacementNodeDB;
|
|
static NodeInfoModule *savedNodeInfoModule;
|
|
static meshtastic_DeviceState savedDeviceState;
|
|
static meshtastic_User savedOwner;
|
|
static meshtastic_LocalConfig savedConfig;
|
|
static meshtastic_ChannelFile savedChannelFile;
|
|
|
|
// Called from setUp/tearDown for every test, not opted into by a handful. A shared NodeDB plus
|
|
// unrestored config/owner/devicestate/channelFile means each test inherits whatever its
|
|
// predecessors left, and the admin handlers under test write all four.
|
|
static void replaceAdminRadioGlobals()
|
|
{
|
|
savedNodeDB = nodeDB;
|
|
savedNodeInfoModule = nodeInfoModule;
|
|
savedDeviceState = devicestate;
|
|
savedOwner = owner;
|
|
savedConfig = config;
|
|
savedChannelFile = channelFile;
|
|
replacementNodeDB = new NodeDB();
|
|
nodeDB = replacementNodeDB;
|
|
}
|
|
|
|
static void restoreAdminRadioGlobals()
|
|
{
|
|
nodeInfoModule = savedNodeInfoModule;
|
|
nodeDB = savedNodeDB;
|
|
delete replacementNodeDB;
|
|
replacementNodeDB = nullptr;
|
|
devicestate = savedDeviceState;
|
|
owner = savedOwner;
|
|
config = savedConfig;
|
|
channelFile = savedChannelFile;
|
|
initRegion();
|
|
}
|
|
|
|
static void installEncryptedAndAdminChannels()
|
|
{
|
|
channels.initDefaults();
|
|
meshtastic_Channel admin = meshtastic_Channel_init_zero;
|
|
admin.index = 1;
|
|
admin.role = meshtastic_Channel_Role_SECONDARY;
|
|
admin.has_settings = true;
|
|
strncpy(admin.settings.name, Channels::adminChannel, sizeof(admin.settings.name));
|
|
admin.settings.psk.size = 16;
|
|
memset(admin.settings.psk.bytes, 0xA5, admin.settings.psk.size);
|
|
channels.setChannel(admin);
|
|
|
|
meshtastic_Channel secondary = meshtastic_Channel_init_zero;
|
|
secondary.index = 2;
|
|
secondary.role = meshtastic_Channel_Role_SECONDARY;
|
|
secondary.has_settings = true;
|
|
strncpy(secondary.settings.name, "private", sizeof(secondary.settings.name));
|
|
secondary.settings.psk.size = 32;
|
|
memset(secondary.settings.psk.bytes, 0x5A, secondary.settings.psk.size);
|
|
channels.setChannel(secondary);
|
|
}
|
|
|
|
static void assertLicensedChannelsSanitized()
|
|
{
|
|
TEST_ASSERT_EQUAL(0, channels.getByIndex(0).settings.psk.size);
|
|
TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(1).role);
|
|
TEST_ASSERT_EQUAL(0, channels.getByIndex(1).settings.psk.size);
|
|
TEST_ASSERT_EQUAL(0, channels.getByIndex(2).settings.psk.size);
|
|
}
|
|
|
|
static void test_handleSetOwner_persistsLicensedChannelSanitation()
|
|
{
|
|
owner = meshtastic_User_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
installEncryptedAndAdminChannels();
|
|
|
|
meshtastic_User licensed = meshtastic_User_init_zero;
|
|
licensed.is_licensed = true;
|
|
testAdmin->deferSaves();
|
|
nodeInfoModule = reinterpret_cast<NodeInfoModule *>(1); // reloadOwner(false) only checks presence
|
|
testAdmin->handleSetOwner(licensed);
|
|
|
|
TEST_ASSERT_TRUE(testAdmin->savedSegments() & SEGMENT_CHANNELS);
|
|
assertLicensedChannelsSanitized();
|
|
|
|
uint8_t encoded[meshtastic_ChannelFile_size];
|
|
const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ChannelFile_msg, &channelFile);
|
|
TEST_ASSERT_GREATER_THAN(0, encodedSize);
|
|
meshtastic_ChannelFile reloaded = meshtastic_ChannelFile_init_zero;
|
|
TEST_ASSERT_TRUE(pb_decode_from_bytes(encoded, encodedSize, &meshtastic_ChannelFile_msg, &reloaded));
|
|
channelFile = reloaded;
|
|
assertLicensedChannelsSanitized();
|
|
TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "sanitized reload must not trigger another persistence write");
|
|
}
|
|
|
|
static void test_bootDefense_sanitizesStaleLicensedChannelsOnce()
|
|
{
|
|
owner = meshtastic_User_init_zero;
|
|
owner.is_licensed = true;
|
|
installEncryptedAndAdminChannels();
|
|
|
|
TEST_ASSERT_TRUE(channels.ensureLicensedOperation());
|
|
assertLicensedChannelsSanitized();
|
|
TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "boot sanitation must be idempotent");
|
|
}
|
|
|
|
static void test_restorePreferences_sanitizesLicensedBackupBeforeReturn()
|
|
{
|
|
NodeDB *savedNodeDB = nodeDB;
|
|
nodeDB = new NodeDB();
|
|
const meshtastic_DeviceState savedDeviceState = devicestate;
|
|
const meshtastic_ChannelFile savedChannelFile = channelFile;
|
|
|
|
owner = meshtastic_User_init_zero;
|
|
owner.is_licensed = true;
|
|
installEncryptedAndAdminChannels();
|
|
TEST_ASSERT_TRUE(nodeDB->backupPreferences(meshtastic_AdminMessage_BackupLocation_FLASH));
|
|
|
|
owner.is_licensed = false;
|
|
channels.initDefaults();
|
|
TEST_ASSERT_TRUE(
|
|
nodeDB->restorePreferences(meshtastic_AdminMessage_BackupLocation_FLASH, SEGMENT_DEVICESTATE | SEGMENT_CHANNELS));
|
|
TEST_ASSERT_TRUE(owner.is_licensed);
|
|
assertLicensedChannelsSanitized();
|
|
TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "restored licensed channels must remain sanitized");
|
|
|
|
devicestate = savedDeviceState;
|
|
channelFile = savedChannelFile;
|
|
nodeDB->saveToDisk(SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
|
|
FSCom.remove(backupFileName);
|
|
delete nodeDB;
|
|
nodeDB = savedNodeDB;
|
|
}
|
|
|
|
static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset,
|
|
meshtastic_Config_LoRaConfig_ModemPreset preset)
|
|
{
|
|
meshtastic_Config c = meshtastic_Config_init_zero;
|
|
c.which_payload_variant = meshtastic_Config_lora_tag;
|
|
c.payload_variant.lora.region = region;
|
|
c.payload_variant.lora.use_preset = usePreset;
|
|
c.payload_variant.lora.modem_preset = preset;
|
|
return c;
|
|
}
|
|
|
|
static void test_handleSetConfig_persistsLicensedFirstRegionIdentity()
|
|
{
|
|
owner = meshtastic_User_init_zero;
|
|
owner.is_licensed = true;
|
|
config.security = meshtastic_Config_SecurityConfig_init_zero;
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
initRegion();
|
|
|
|
testAdmin->deferSaves();
|
|
const meshtastic_Config c =
|
|
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
|
|
testAdmin->handleSetConfig(c, false);
|
|
|
|
const int expectedSegments = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
|
|
TEST_ASSERT_EQUAL_INT(expectedSegments, testAdmin->savedSegments());
|
|
TEST_ASSERT_EQUAL(32, config.security.private_key.size);
|
|
TEST_ASSERT_EQUAL(32, owner.public_key.size);
|
|
}
|
|
|
|
static void test_handleSetConfig_fromOthers_invalidPresetRejected()
|
|
{
|
|
// Set up a known-good baseline in the global config
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
initRegion();
|
|
|
|
// Build an admin set_config with an invalid preset for EU_868
|
|
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
|
|
|
|
testAdmin->handleSetConfig(c, true); // fromOthers = true
|
|
|
|
// fromOthers=true: invalid preset should be rejected, old preset preserved
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
|
|
}
|
|
|
|
static void test_handleSetConfig_fromLocal_invalidPresetClamped()
|
|
{
|
|
// Set up a known-good baseline
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
initRegion();
|
|
|
|
// Build an admin set_config with an invalid preset for EU_868
|
|
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO);
|
|
|
|
testAdmin->handleSetConfig(c, false); // fromOthers = false (local client)
|
|
|
|
// fromOthers=false: invalid preset should be clamped to the region's default
|
|
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), config.lora.modem_preset);
|
|
}
|
|
|
|
static void test_handleSetConfig_fromOthers_validPresetAccepted()
|
|
{
|
|
// Set up baseline
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
initRegion();
|
|
|
|
// Build an admin set_config with a valid preset for EU_868
|
|
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST);
|
|
|
|
testAdmin->handleSetConfig(c, true); // fromOthers = true
|
|
|
|
// Valid preset should be accepted regardless of fromOthers
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset);
|
|
}
|
|
|
|
static void test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected()
|
|
{
|
|
// Rejecting a remote config must reject ALL of it: an invalid channel_num must not
|
|
// leak into config.lora alongside the restored region/preset.
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
config.lora.channel_num = 0;
|
|
initRegion();
|
|
|
|
meshtastic_Config c =
|
|
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
|
|
c.payload_variant.lora.channel_num = 5000; // far beyond US slot count
|
|
|
|
testAdmin->handleSetConfig(c, true); // fromOthers = true
|
|
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
|
|
TEST_ASSERT_EQUAL_UINT32(0, config.lora.channel_num);
|
|
}
|
|
|
|
// clampBandwidthCode: an unset (0) bandwidth code maps to the default; any other code is left as-is.
|
|
static void test_clampBandwidthCode_zeroMapsToDefaultOthersUnchanged()
|
|
{
|
|
TEST_ASSERT_NOT_EQUAL_UINT16(0, clampBandwidthCode(0)); // the point of the fix: 0 must not stay 0
|
|
TEST_ASSERT_EQUAL_UINT16(bwKHzToCode(LORA_BW_DEFAULT_KHZ), clampBandwidthCode(0));
|
|
TEST_ASSERT_EQUAL_UINT16(250, clampBandwidthCode(250));
|
|
TEST_ASSERT_EQUAL_UINT16(125, clampBandwidthCode(125));
|
|
TEST_ASSERT_EQUAL_UINT16(31, clampBandwidthCode(31));
|
|
}
|
|
|
|
// A custom (non-preset) config that leaves bandwidth at its proto zero-value must not persist as 0.
|
|
// Pre-fix it slipped past validateConfigLora() and the radio silently ran at the default while
|
|
// get_config still reported bandwidth 0. It is now coerced to the default code on ingest.
|
|
static void test_handleSetConfig_fromLocal_customBandwidthZeroClampedToDefault()
|
|
{
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
initRegion();
|
|
|
|
meshtastic_Config c =
|
|
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, false, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
|
|
c.payload_variant.lora.spread_factor = 11;
|
|
c.payload_variant.lora.coding_rate = 5;
|
|
c.payload_variant.lora.bandwidth = 0; // the footgun: unset custom bandwidth
|
|
|
|
testAdmin->handleSetConfig(c, false); // fromOthers = false (local client)
|
|
|
|
TEST_ASSERT_FALSE(config.lora.use_preset);
|
|
TEST_ASSERT_NOT_EQUAL_UINT16(0, config.lora.bandwidth); // must not persist as 0
|
|
TEST_ASSERT_EQUAL_UINT16(bwKHzToCode(LORA_BW_DEFAULT_KHZ), config.lora.bandwidth);
|
|
}
|
|
|
|
// Remote admin (fromOthers) is subject to the same ingest clamp: a custom bandwidth 0 from another
|
|
// node is normalized to the default rather than persisted as 0 (it does not weaken the wholesale
|
|
// rejection of configs that actually fail validation - a 0 bandwidth already passed validation).
|
|
static void test_handleSetConfig_fromOthers_customBandwidthZeroClampedToDefault()
|
|
{
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
initRegion();
|
|
|
|
meshtastic_Config c =
|
|
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, false, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
|
|
c.payload_variant.lora.spread_factor = 11;
|
|
c.payload_variant.lora.coding_rate = 5;
|
|
c.payload_variant.lora.bandwidth = 0;
|
|
|
|
testAdmin->handleSetConfig(c, true); // fromOthers = true
|
|
|
|
TEST_ASSERT_FALSE(config.lora.use_preset);
|
|
TEST_ASSERT_EQUAL_UINT16(bwKHzToCode(LORA_BW_DEFAULT_KHZ), config.lora.bandwidth);
|
|
}
|
|
|
|
// In preset mode bandwidth 0 is the norm (the preset supplies it); the ingest clamp must leave it
|
|
// untouched so preset configs still read back bandwidth 0.
|
|
static void test_handleSetConfig_fromLocal_presetBandwidthZeroLeftUntouched()
|
|
{
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
initRegion();
|
|
|
|
meshtastic_Config c =
|
|
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
|
|
c.payload_variant.lora.bandwidth = 0;
|
|
|
|
testAdmin->handleSetConfig(c, false);
|
|
|
|
TEST_ASSERT_TRUE(config.lora.use_preset);
|
|
TEST_ASSERT_EQUAL_UINT16(0, config.lora.bandwidth);
|
|
}
|
|
|
|
// A custom (non-preset) config with an already-valid bandwidth must be preserved verbatim.
|
|
static void test_handleSetConfig_fromLocal_customBandwidthNonZeroPreserved()
|
|
{
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
initRegion();
|
|
|
|
meshtastic_Config c =
|
|
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, false, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
|
|
c.payload_variant.lora.spread_factor = 11;
|
|
c.payload_variant.lora.coding_rate = 5;
|
|
c.payload_variant.lora.bandwidth = 125;
|
|
|
|
testAdmin->handleSetConfig(c, false);
|
|
|
|
TEST_ASSERT_FALSE(config.lora.use_preset);
|
|
TEST_ASSERT_EQUAL_UINT16(125, config.lora.bandwidth);
|
|
}
|
|
|
|
// A security-config SET that omits the private key (partial/legacy client editing some other security field)
|
|
// must NOT regenerate our keypair: our NodeNum is crc32(public_key), so a new keypair would silently change
|
|
// our identity. The existing keypair has to be preserved.
|
|
static void test_handleSetConfig_security_preservesKeypairWhenPrivateOmitted()
|
|
{
|
|
config.security = meshtastic_Config_SecurityConfig_init_zero;
|
|
config.security.private_key.size = 32;
|
|
memset(config.security.private_key.bytes, 0x11, 32);
|
|
config.security.public_key.size = 32;
|
|
memset(config.security.public_key.bytes, 0x22, 32);
|
|
|
|
// Incoming SET carries no private/public key, just another security field.
|
|
meshtastic_Config c = meshtastic_Config_init_zero;
|
|
c.which_payload_variant = meshtastic_Config_security_tag;
|
|
c.payload_variant.security.serial_enabled = true;
|
|
|
|
testAdmin->deferSaves();
|
|
testAdmin->handleSetConfig(c, false);
|
|
|
|
uint8_t expectedPriv[32];
|
|
memset(expectedPriv, 0x11, 32);
|
|
uint8_t expectedPub[32];
|
|
memset(expectedPub, 0x22, 32);
|
|
TEST_ASSERT_EQUAL_UINT(32, config.security.private_key.size);
|
|
TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32);
|
|
TEST_ASSERT_EQUAL_UINT(32, config.security.public_key.size);
|
|
TEST_ASSERT_EQUAL_MEMORY(expectedPub, config.security.public_key.bytes, 32);
|
|
// The non-key field still applies.
|
|
TEST_ASSERT_TRUE(config.security.serial_enabled);
|
|
}
|
|
|
|
// A SET that DOES supply a full 32-byte keypair (legitimate key import) must apply it, not preserve the old one.
|
|
static void test_handleSetConfig_security_acceptsSuppliedKeypair()
|
|
{
|
|
config.security = meshtastic_Config_SecurityConfig_init_zero;
|
|
config.security.private_key.size = 32;
|
|
memset(config.security.private_key.bytes, 0x11, 32);
|
|
config.security.public_key.size = 32;
|
|
memset(config.security.public_key.bytes, 0x22, 32);
|
|
|
|
meshtastic_Config c = meshtastic_Config_init_zero;
|
|
c.which_payload_variant = meshtastic_Config_security_tag;
|
|
c.payload_variant.security.private_key.size = 32;
|
|
memset(c.payload_variant.security.private_key.bytes, 0x33, 32);
|
|
c.payload_variant.security.public_key.size = 32;
|
|
memset(c.payload_variant.security.public_key.bytes, 0x44, 32);
|
|
|
|
testAdmin->deferSaves();
|
|
testAdmin->handleSetConfig(c, false);
|
|
|
|
uint8_t expectedPriv[32];
|
|
memset(expectedPriv, 0x33, 32);
|
|
uint8_t expectedPub[32];
|
|
memset(expectedPub, 0x44, 32);
|
|
TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32);
|
|
TEST_ASSERT_EQUAL_MEMORY(expectedPub, config.security.public_key.bytes, 32);
|
|
}
|
|
|
|
// Issue #11073: "regenerate keys" sends a blank SecurityConfig holding only the new private key. Replacing
|
|
// the whole struct with it wiped the admin keys, locking the owner out of remote admin.
|
|
static void test_handleSetConfig_security_rotationPreservesAdminKeys()
|
|
{
|
|
config.security = meshtastic_Config_SecurityConfig_init_zero;
|
|
config.security.private_key.size = 32;
|
|
memset(config.security.private_key.bytes, 0x11, 32);
|
|
config.security.public_key.size = 32;
|
|
memset(config.security.public_key.bytes, 0x22, 32);
|
|
config.security.admin_key_count = 2;
|
|
config.security.admin_key[0].size = 32;
|
|
memset(config.security.admin_key[0].bytes, 0xAA, 32);
|
|
config.security.admin_key[1].size = 32;
|
|
memset(config.security.admin_key[1].bytes, 0xBB, 32);
|
|
config.security.is_managed = true;
|
|
config.security.serial_enabled = true;
|
|
config.security.packet_signature_policy =
|
|
meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT;
|
|
|
|
// Exactly what the regenerate dialog emits.
|
|
meshtastic_Config c = meshtastic_Config_init_zero;
|
|
c.which_payload_variant = meshtastic_Config_security_tag;
|
|
c.payload_variant.security.private_key.size = 32;
|
|
memset(c.payload_variant.security.private_key.bytes, 0x33, 32);
|
|
|
|
testAdmin->deferSaves();
|
|
testAdmin->handleSetConfig(c, false);
|
|
|
|
uint8_t expectedPriv[32];
|
|
memset(expectedPriv, 0x33, 32);
|
|
TEST_ASSERT_EQUAL_UINT(32, config.security.private_key.size);
|
|
TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32);
|
|
|
|
uint8_t expectedAdmin0[32], expectedAdmin1[32];
|
|
memset(expectedAdmin0, 0xAA, 32);
|
|
memset(expectedAdmin1, 0xBB, 32);
|
|
TEST_ASSERT_EQUAL_UINT(2, config.security.admin_key_count);
|
|
TEST_ASSERT_EQUAL_UINT(32, config.security.admin_key[0].size);
|
|
TEST_ASSERT_EQUAL_MEMORY(expectedAdmin0, config.security.admin_key[0].bytes, 32);
|
|
TEST_ASSERT_EQUAL_UINT(32, config.security.admin_key[1].size);
|
|
TEST_ASSERT_EQUAL_MEMORY(expectedAdmin1, config.security.admin_key[1].bytes, 32);
|
|
TEST_ASSERT_TRUE(config.security.is_managed);
|
|
TEST_ASSERT_TRUE(config.security.serial_enabled);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT,
|
|
config.security.packet_signature_policy);
|
|
}
|
|
|
|
// The escape hatch: a SET that leaves the private key alone still clears admin keys.
|
|
static void test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged()
|
|
{
|
|
config.security = meshtastic_Config_SecurityConfig_init_zero;
|
|
config.security.private_key.size = 32;
|
|
memset(config.security.private_key.bytes, 0x11, 32);
|
|
config.security.public_key.size = 32;
|
|
memset(config.security.public_key.bytes, 0x22, 32);
|
|
config.security.admin_key_count = 1;
|
|
config.security.admin_key[0].size = 32;
|
|
memset(config.security.admin_key[0].bytes, 0xAA, 32);
|
|
|
|
// Same private key we already hold, empty admin key list.
|
|
meshtastic_Config c = meshtastic_Config_init_zero;
|
|
c.which_payload_variant = meshtastic_Config_security_tag;
|
|
c.payload_variant.security.private_key.size = 32;
|
|
memset(c.payload_variant.security.private_key.bytes, 0x11, 32);
|
|
c.payload_variant.security.public_key.size = 32;
|
|
memset(c.payload_variant.security.public_key.bytes, 0x22, 32);
|
|
|
|
testAdmin->deferSaves();
|
|
testAdmin->handleSetConfig(c, false);
|
|
|
|
TEST_ASSERT_EQUAL_UINT(0, config.security.admin_key_count);
|
|
TEST_ASSERT_EQUAL_UINT(0, config.security.admin_key[0].size);
|
|
}
|
|
|
|
static void test_regionInfo_supportsPreset()
|
|
{
|
|
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
|
|
TEST_ASSERT_TRUE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
|
|
TEST_ASSERT_FALSE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO));
|
|
TEST_ASSERT_FALSE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST));
|
|
|
|
const RegionInfo *eu866 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866);
|
|
TEST_ASSERT_TRUE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW));
|
|
TEST_ASSERT_FALSE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
|
|
}
|
|
|
|
static void test_checkConfigRegion_quietCheckReportsReason()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
TEST_ASSERT_TRUE(RadioInterface::checkConfigRegion(cfg));
|
|
|
|
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)254;
|
|
char err[160] = {0};
|
|
TEST_ASSERT_FALSE(RadioInterface::checkConfigRegion(cfg, err, sizeof(err)));
|
|
TEST_ASSERT_TRUE_MESSAGE(strlen(err) > 0, "Expected a failure reason in errBuf");
|
|
}
|
|
|
|
static void test_checkConfigRegion_allowsProspectiveLicensedOwner()
|
|
{
|
|
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
|
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M;
|
|
devicestate.owner.is_licensed = false;
|
|
|
|
TEST_ASSERT_FALSE(RadioInterface::checkConfigRegion(cfg));
|
|
TEST_ASSERT_TRUE(RadioInterface::checkConfigRegion(cfg, nullptr, 0, true));
|
|
}
|
|
|
|
static void test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion()
|
|
{
|
|
// Baseline: EU_866 (LITE profile)
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST;
|
|
initRegion();
|
|
|
|
// Remote admin keeps the region but selects a NARROW preset (locked to EU_N_868)
|
|
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_866, true,
|
|
meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST);
|
|
|
|
testAdmin->handleSetConfig(c, true); // fromOthers = true
|
|
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, config.lora.region);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST, config.lora.modem_preset);
|
|
|
|
// Restore the region table pointer for subsequent tests
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
initRegion();
|
|
}
|
|
|
|
static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected()
|
|
{
|
|
// Baseline: US is not one of the swappable trio, so a LITE preset must be rejected
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
initRegion();
|
|
|
|
meshtastic_Config c =
|
|
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST);
|
|
|
|
testAdmin->handleSetConfig(c, true); // fromOthers = true
|
|
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
|
|
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Channel-configuration warning + coalescing tests
|
|
//
|
|
// These exercise the real incoming-admin-message path (handleReceivedProtobuf):
|
|
// begin_edit_settings / set_channel / commit_edit_settings. Warnings raised while a
|
|
// transaction is open must be deferred and collapsed into a single notification at
|
|
// commit; outside a transaction each save emits its own single message immediately.
|
|
// -----------------------------------------------------------------------
|
|
|
|
static const uint8_t DEFAULT_KEY[] = {0x01}; // the well-known "default" PSK (AQ==)
|
|
static const uint8_t CUSTOM_KEY[] = {0x42, 0x17}; // any non-default key
|
|
|
|
// Count captured warnings whose text contains substr.
|
|
static int warningsContaining(const char *substr)
|
|
{
|
|
int n = 0;
|
|
for (const auto &w : capturedWarnings)
|
|
if (w.find(substr) != std::string::npos)
|
|
n++;
|
|
return n;
|
|
}
|
|
|
|
static meshtastic_Channel makeChannel(int8_t index, meshtastic_Channel_Role role, const char *name, const uint8_t *psk,
|
|
size_t pskLen)
|
|
{
|
|
meshtastic_Channel ch = meshtastic_Channel_init_zero;
|
|
ch.index = index;
|
|
ch.role = role;
|
|
ch.has_settings = true;
|
|
strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1);
|
|
ch.settings.psk.size = pskLen;
|
|
for (size_t i = 0; i < pskLen; i++)
|
|
ch.settings.psk.bytes[i] = psk[i];
|
|
return ch;
|
|
}
|
|
|
|
// Dispatch one admin message as if it arrived from a local (from==0) client, which bypasses
|
|
// the passkey/authorization gates so the switch body runs.
|
|
static void sendAdmin(meshtastic_AdminMessage &m)
|
|
{
|
|
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
|
|
mp.from = 0;
|
|
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; // required: handler drops non-decoded packets
|
|
testAdmin->handleReceivedProtobuf(mp, &m);
|
|
}
|
|
|
|
static void sendSetChannel(const meshtastic_Channel &ch)
|
|
{
|
|
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
|
m.which_payload_variant = meshtastic_AdminMessage_set_channel_tag;
|
|
m.set_channel = ch;
|
|
sendAdmin(m);
|
|
}
|
|
|
|
static void sendBeginEdit()
|
|
{
|
|
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
|
m.which_payload_variant = meshtastic_AdminMessage_begin_edit_settings_tag;
|
|
m.begin_edit_settings = true;
|
|
sendAdmin(m);
|
|
}
|
|
|
|
static void sendCommitEdit()
|
|
{
|
|
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
|
m.which_payload_variant = meshtastic_AdminMessage_commit_edit_settings_tag;
|
|
m.commit_edit_settings = true;
|
|
sendAdmin(m);
|
|
}
|
|
|
|
// An admin message that changes nothing. It answers, so drain the reply or the packet pool leaks.
|
|
static void sendGetDeviceMetadata()
|
|
{
|
|
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
|
m.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_request_tag;
|
|
m.get_device_metadata_request = true;
|
|
sendAdmin(m);
|
|
testAdmin->drainReply();
|
|
}
|
|
|
|
// Preset = LongFast on US, unlicensed owner. "LongFast" is the display name we compare against.
|
|
static void usePresetLongFast()
|
|
{
|
|
config.lora = meshtastic_Config_LoRaConfig_init_zero;
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
|
|
config.lora.use_preset = true;
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
initRegion();
|
|
owner.is_licensed = false;
|
|
}
|
|
|
|
static void test_warn_singleChannel_variantName_oneSpecificMessage()
|
|
{
|
|
usePresetLongFast();
|
|
// Name is a case/space variant of the preset with the default key: a single name issue.
|
|
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
|
|
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
|
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
|
|
}
|
|
|
|
static void test_warn_singleChannel_nameAndPsk_collapsedToCatchAll()
|
|
{
|
|
usePresetLongFast();
|
|
// Variant name AND a non-default key: two issues on one channel collapse to one catch-all.
|
|
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", CUSTOM_KEY, 2));
|
|
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
|
TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name and PSK issues on channel 0"));
|
|
}
|
|
|
|
static void test_warn_cleanChannel_noMessage()
|
|
{
|
|
usePresetLongFast();
|
|
// Exact preset name + default key: nothing to warn about.
|
|
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", DEFAULT_KEY, 1));
|
|
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
|
|
}
|
|
|
|
static void test_warn_transaction_multipleChannels_singleCoalescedMessage()
|
|
{
|
|
usePresetLongFast();
|
|
sendBeginEdit();
|
|
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
|
|
sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1));
|
|
// Nothing emitted yet - warnings are deferred until commit.
|
|
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
|
|
|
|
sendCommitEdit();
|
|
// Exactly one message, naming both channels.
|
|
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
|
TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1"));
|
|
}
|
|
|
|
static void test_warn_transaction_singleChannel_keepsSpecificMessage()
|
|
{
|
|
usePresetLongFast();
|
|
sendBeginEdit();
|
|
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
|
|
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
|
|
|
|
sendCommitEdit();
|
|
// One flagged channel: the specific message verbatim, not the plural catch-all.
|
|
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
|
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
|
|
TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels"));
|
|
}
|
|
|
|
// An idle transaction is retired by the next admin message, flushing the warnings it held.
|
|
static void test_editTransaction_abandoned_isRetiredOnNextAdminMessage()
|
|
{
|
|
usePresetLongFast();
|
|
sendBeginEdit();
|
|
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
|
|
// Deferred, exactly as before: nothing emitted while the transaction looks alive.
|
|
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
|
|
TEST_ASSERT_TRUE(testAdmin->editTransactionOpen());
|
|
|
|
testAdmin->ageEditTransaction();
|
|
sendGetDeviceMetadata(); // any later admin message, from any client
|
|
|
|
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
|
|
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
|
|
}
|
|
|
|
// A write arriving after abandonment is saved, not deferred to a commit that never comes.
|
|
static void test_editTransaction_abandoned_laterWriteIsNoLongerDeferred()
|
|
{
|
|
usePresetLongFast();
|
|
sendBeginEdit();
|
|
testAdmin->ageEditTransaction();
|
|
|
|
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
|
|
|
|
// The write itself retired the stale transaction, so its own warning is emitted immediately.
|
|
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
|
|
TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'"));
|
|
}
|
|
|
|
// A transaction still in use is left alone: each write refreshes the window.
|
|
static void test_editTransaction_active_isNotRetired()
|
|
{
|
|
usePresetLongFast();
|
|
sendBeginEdit();
|
|
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1));
|
|
sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1));
|
|
|
|
TEST_ASSERT_TRUE(testAdmin->editTransactionOpen());
|
|
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
|
|
|
|
sendCommitEdit();
|
|
TEST_ASSERT_FALSE(testAdmin->editTransactionOpen());
|
|
TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1"));
|
|
}
|
|
|
|
static void test_warn_license_noTransaction_emittedImmediately()
|
|
{
|
|
usePresetLongFast();
|
|
owner.is_licensed = true;
|
|
// Setting a channel that still carries a key triggers ensureLicensedOperation() to strip it.
|
|
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2));
|
|
TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated"));
|
|
}
|
|
|
|
static void test_warn_license_transaction_coalescedToSingleMessage()
|
|
{
|
|
usePresetLongFast();
|
|
owner.is_licensed = true;
|
|
sendBeginEdit();
|
|
// Two separate triggers within one transaction (two channels with keys to strip).
|
|
sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2));
|
|
sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "", CUSTOM_KEY, 2));
|
|
TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size());
|
|
|
|
sendCommitEdit();
|
|
// Collapsed to a single licensed-mode notice (and no channel warning, since names are blank).
|
|
TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated"));
|
|
TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Node-DB admin metadata: favorite / ignore / mute
|
|
// -----------------------------------------------------------------------
|
|
//
|
|
// MeshService::reloadConfig() only re-derives the region and fires configChanged - which drives the
|
|
// live SX126x/RadioInterface reconfigure - when saveWhat includes SEGMENT_CONFIG or
|
|
// SEGMENT_CHANNELS. A pure node-DB metadata save must skip that reconfigure entirely. These watch
|
|
// service->configChanged directly, so widening the saveWhat mask or reordering the check is caught
|
|
// even though they run outside an edit transaction.
|
|
//
|
|
// Characterization: all three already hold on develop. They are worth pinning because that reload
|
|
// is the path implicated in the WisMesh Tag favourite-node crash, and nothing asserted it.
|
|
|
|
// Counts configChanged.notifyObservers() calls - the only externally visible signal that
|
|
// reloadConfig() took the radio-reconfigure branch.
|
|
class ConfigChangedCounter : public Observer<void *>
|
|
{
|
|
public:
|
|
int count = 0;
|
|
|
|
protected:
|
|
int onNotify(void *arg) override
|
|
{
|
|
count++;
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
static const NodeNum TEST_NODE_NUM = 0x12345678;
|
|
|
|
static void test_setFavoriteNode_skipsRadioReload_butPersists()
|
|
{
|
|
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
|
|
ConfigChangedCounter counter;
|
|
counter.observe(&service->configChanged);
|
|
|
|
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
|
m.which_payload_variant = meshtastic_AdminMessage_set_favorite_node_tag;
|
|
m.set_favorite_node = TEST_NODE_NUM;
|
|
sendAdmin(m);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(nodeDB->getMeshNode(TEST_NODE_NUM)));
|
|
}
|
|
|
|
static void test_setIgnoredNode_skipsRadioReload_butPersists()
|
|
{
|
|
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
|
|
ConfigChangedCounter counter;
|
|
counter.observe(&service->configChanged);
|
|
|
|
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
|
m.which_payload_variant = meshtastic_AdminMessage_set_ignored_node_tag;
|
|
m.set_ignored_node = TEST_NODE_NUM;
|
|
sendAdmin(m);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(nodeDB->getMeshNode(TEST_NODE_NUM)));
|
|
}
|
|
|
|
static void test_toggleMutedNode_skipsRadioReload_butPersists()
|
|
{
|
|
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
|
|
ConfigChangedCounter counter;
|
|
counter.observe(&service->configChanged);
|
|
|
|
meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero;
|
|
m.which_payload_variant = meshtastic_AdminMessage_toggle_muted_node_tag;
|
|
m.toggle_muted_node = TEST_NODE_NUM;
|
|
sendAdmin(m);
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsMuted(nodeDB->getMeshNode(TEST_NODE_NUM)));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Node menu mute toggle (graphics::menuHandler::toggleNodeMuted)
|
|
// -----------------------------------------------------------------------
|
|
//
|
|
// Reachable only since the mute branch was lifted out of its banner-callback lambda; the lambda
|
|
// runs via screen->showOverlayBanner(), so nothing in MenuHandler.cpp was testable before.
|
|
|
|
#if HAS_SCREEN
|
|
static void test_toggleNodeMuted_flipsBitAndSkipsRadioReload()
|
|
{
|
|
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
|
|
ConfigChangedCounter counter;
|
|
counter.observe(&service->configChanged);
|
|
|
|
graphics::menuHandler::toggleNodeMuted(TEST_NODE_NUM);
|
|
TEST_ASSERT_TRUE(nodeInfoLiteIsMuted(nodeDB->getMeshNode(TEST_NODE_NUM)));
|
|
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
|
|
|
graphics::menuHandler::toggleNodeMuted(TEST_NODE_NUM);
|
|
TEST_ASSERT_FALSE(nodeInfoLiteIsMuted(nodeDB->getMeshNode(TEST_NODE_NUM)));
|
|
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
|
}
|
|
|
|
static void test_toggleNodeMuted_unknownNodeDoesNothing()
|
|
{
|
|
ConfigChangedCounter counter;
|
|
counter.observe(&service->configChanged);
|
|
|
|
graphics::menuHandler::toggleNodeMuted(0xDEADBEEF); // never added to the DB
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, counter.count);
|
|
TEST_ASSERT_NULL(nodeDB->getMeshNode(0xDEADBEEF));
|
|
}
|
|
|
|
// CHARACTERIZATION OF A KNOWN DEFECT, not an endorsement. Flipping one NodeInfoLite bit currently
|
|
// calls bare nodeDB->saveToDisk(), which rewrites all five segments. saveToDisk() is not virtual,
|
|
// so the mask is observed through its effect: every prefs file reappears after being removed.
|
|
//
|
|
// A pending fix narrows this to SEGMENT_NODEDATABASE. When it lands, only nodes.proto should come
|
|
// back and this assertion is EXPECTED to change - that diff is the point, so the improvement is
|
|
// visible instead of silent.
|
|
static void test_toggleNodeMuted_currentlyRewritesEverySegment()
|
|
{
|
|
nodeDB->getOrCreateMeshNode(TEST_NODE_NUM);
|
|
|
|
const char *segmentFiles[] = {configFileName, moduleConfigFileName, deviceStateFileName, channelFileName,
|
|
nodeDatabaseFileName};
|
|
for (const char *f : segmentFiles)
|
|
FSCom.remove(f);
|
|
|
|
graphics::menuHandler::toggleNodeMuted(TEST_NODE_NUM);
|
|
|
|
for (const char *f : segmentFiles)
|
|
TEST_ASSERT_TRUE_MESSAGE(FSCom.exists(f), f);
|
|
}
|
|
#endif // HAS_SCREEN
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Test runner
|
|
// -----------------------------------------------------------------------
|
|
|
|
void setUp(void)
|
|
{
|
|
mockMeshService = new MockMeshService();
|
|
service = mockMeshService;
|
|
testAdmin = new AdminModuleTestShim();
|
|
capturedWarnings.clear();
|
|
// Every test gets its own NodeDB and its own copy of the globals the admin handlers write.
|
|
replaceAdminRadioGlobals();
|
|
}
|
|
void tearDown(void)
|
|
{
|
|
restoreAdminRadioGlobals();
|
|
service = nullptr;
|
|
delete mockMeshService;
|
|
mockMeshService = nullptr;
|
|
delete testAdmin;
|
|
testAdmin = nullptr;
|
|
}
|
|
|
|
void setup()
|
|
{
|
|
delay(10);
|
|
delay(2000);
|
|
|
|
initializeTestEnvironment();
|
|
|
|
UNITY_BEGIN();
|
|
|
|
// getRegion()
|
|
RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation);
|
|
RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity);
|
|
RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce);
|
|
RUN_TEST(test_restorePreferences_sanitizesLicensedBackupBeforeReturn);
|
|
RUN_TEST(test_getRegion_returnsCorrectRegion_US);
|
|
RUN_TEST(test_getRegion_returnsCorrectRegion_EU868);
|
|
RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24);
|
|
RUN_TEST(test_getRegion_unsetCodeReturnsUnsetEntry);
|
|
RUN_TEST(test_getRegion_unknownCodeFallsToUnset);
|
|
|
|
// validateConfigRegion()
|
|
RUN_TEST(test_validateConfigRegion_validRegionReturnsTrue);
|
|
RUN_TEST(test_validateConfigRegion_unsetRegionReturnsTrue);
|
|
RUN_TEST(test_validateConfigRegion_unknownCodeReturnsFalse);
|
|
RUN_TEST(test_validateConfigRegion_anotherUnknownCodeReturnsFalse);
|
|
|
|
// Shadow table tests
|
|
RUN_TEST(test_shadowTable_spacedProfileHasNonZeroSpacing);
|
|
RUN_TEST(test_shadowTable_licensedProfileFlagsCorrect);
|
|
RUN_TEST(test_shadowTable_presetCountMatchesExpected);
|
|
RUN_TEST(test_shadowTable_defaultPresetIsFirstInList);
|
|
RUN_TEST(test_shadowTable_channelSpacingWithPadding);
|
|
RUN_TEST(test_shadowTable_turboOnlyOnWideLora);
|
|
RUN_TEST(test_shadowTable_unknownCodeFallsToSentinel);
|
|
RUN_TEST(test_shadowTable_presetHashProfileHasCorrectOverrideSlot);
|
|
|
|
// validateConfigLora()
|
|
RUN_TEST(test_validateConfigLora_validPresetForUS);
|
|
RUN_TEST(test_validateConfigLora_allStdPresetsValidForUS);
|
|
RUN_TEST(test_validateConfigLora_turboPresetsInvalidForEU868);
|
|
RUN_TEST(test_validateConfigLora_validPresetsForEU868);
|
|
RUN_TEST(test_validateConfigLora_customBandwidthTooWideForEU868);
|
|
RUN_TEST(test_validateConfigLora_customBandwidthFitsUS);
|
|
RUN_TEST(test_validateConfigLora_customBandwidthFitsEU868);
|
|
RUN_TEST(test_validateConfigLora_bogusPresetRejected);
|
|
RUN_TEST(test_validateConfigLora_unsetRegionOnlyAcceptsLongFast);
|
|
RUN_TEST(test_validateConfigLora_allPresetsValidForLORA24);
|
|
|
|
// clampConfigLora()
|
|
RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault);
|
|
RUN_TEST(test_clampConfigLora_validPresetUnchanged);
|
|
RUN_TEST(test_clampConfigLora_customBwTooWideClampedToDefaultBw);
|
|
RUN_TEST(test_clampConfigLora_customBwValidLeftUnchanged);
|
|
RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast);
|
|
RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault);
|
|
|
|
// Region-locked preset swap
|
|
RUN_TEST(test_clampConfigLora_narrowPresetOnEU866SwapsToEUN868);
|
|
RUN_TEST(test_clampConfigLora_litePresetOnEU868SwapsToEU866);
|
|
RUN_TEST(test_clampConfigLora_eu868PresetOnEUN868SwapsToEU868);
|
|
RUN_TEST(test_clampConfigLora_litePresetOnUSDoesNotSwap);
|
|
RUN_TEST(test_clampConfigLora_narrowPresetOnHam125cmDoesNotSwap);
|
|
RUN_TEST(test_validateConfigLora_siblingLockedPresetStillFailsValidation);
|
|
|
|
// RegionInfo preset list integrity
|
|
RUN_TEST(test_presetsStd_hasTenEntries);
|
|
RUN_TEST(test_presetsEU868_hasSevenEntries);
|
|
RUN_TEST(test_presetsUndef_hasOneEntry);
|
|
RUN_TEST(test_defaultPresetIsInAvailablePresets);
|
|
RUN_TEST(test_regionFieldsAreSane);
|
|
RUN_TEST(test_onlyLORA24HasWideLora);
|
|
|
|
// OVERRIDE_SLOT_PRESET_HASH (-1) slot formula tests
|
|
RUN_TEST(test_overrideSlotPresetHash_longFast_customChannelMatchesDefaultNameSlot);
|
|
RUN_TEST(test_overrideSlotPresetHash_mediumFast_customChannelMatchesDefaultNameSlot);
|
|
RUN_TEST(test_overrideSlotPresetHash_longFast_slotIsStableAcrossCustomNames);
|
|
RUN_TEST(test_overrideSlotPresetHash_mediumFast_slotIsStableAcrossCustomNames);
|
|
RUN_TEST(test_overrideSlotPresetHash_longFastAndMediumFast_slotsAreDifferentPresets);
|
|
|
|
// Channel spacing (current + placeholder)
|
|
RUN_TEST(test_channelSpacingCalculation_US_LONG_FAST);
|
|
RUN_TEST(test_channelSpacingCalculation_EU868_LONG_FAST);
|
|
RUN_TEST(test_channelSpacingCalculation_placeholder);
|
|
|
|
// handleSetConfig fromOthers dispatch
|
|
RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected);
|
|
RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped);
|
|
RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted);
|
|
RUN_TEST(test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected);
|
|
RUN_TEST(test_clampBandwidthCode_zeroMapsToDefaultOthersUnchanged);
|
|
RUN_TEST(test_handleSetConfig_fromLocal_customBandwidthZeroClampedToDefault);
|
|
RUN_TEST(test_handleSetConfig_fromOthers_customBandwidthZeroClampedToDefault);
|
|
RUN_TEST(test_handleSetConfig_fromLocal_presetBandwidthZeroLeftUntouched);
|
|
RUN_TEST(test_handleSetConfig_fromLocal_customBandwidthNonZeroPreserved);
|
|
RUN_TEST(test_handleSetConfig_security_preservesKeypairWhenPrivateOmitted);
|
|
RUN_TEST(test_handleSetConfig_security_acceptsSuppliedKeypair);
|
|
RUN_TEST(test_handleSetConfig_security_rotationPreservesAdminKeys);
|
|
RUN_TEST(test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged);
|
|
RUN_TEST(test_regionInfo_supportsPreset);
|
|
RUN_TEST(test_checkConfigRegion_quietCheckReportsReason);
|
|
RUN_TEST(test_checkConfigRegion_allowsProspectiveLicensedOwner);
|
|
RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion);
|
|
RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected);
|
|
|
|
// Channel-configuration warning + coalescing
|
|
RUN_TEST(test_warn_singleChannel_variantName_oneSpecificMessage);
|
|
RUN_TEST(test_warn_singleChannel_nameAndPsk_collapsedToCatchAll);
|
|
RUN_TEST(test_warn_cleanChannel_noMessage);
|
|
RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage);
|
|
RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage);
|
|
RUN_TEST(test_editTransaction_abandoned_isRetiredOnNextAdminMessage);
|
|
RUN_TEST(test_editTransaction_abandoned_laterWriteIsNoLongerDeferred);
|
|
RUN_TEST(test_editTransaction_active_isNotRetired);
|
|
RUN_TEST(test_warn_license_noTransaction_emittedImmediately);
|
|
RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage);
|
|
|
|
// Node-DB metadata saves must not reconfigure the radio
|
|
RUN_TEST(test_setFavoriteNode_skipsRadioReload_butPersists);
|
|
RUN_TEST(test_setIgnoredNode_skipsRadioReload_butPersists);
|
|
RUN_TEST(test_toggleMutedNode_skipsRadioReload_butPersists);
|
|
|
|
#if HAS_SCREEN
|
|
// Node menu mute toggle
|
|
RUN_TEST(test_toggleNodeMuted_flipsBitAndSkipsRadioReload);
|
|
RUN_TEST(test_toggleNodeMuted_unknownNodeDoesNothing);
|
|
RUN_TEST(test_toggleNodeMuted_currentlyRewritesEverySegment);
|
|
#endif
|
|
|
|
exit(UNITY_END());
|
|
}
|
|
|
|
void loop() {}
|