From 159c0864856dd87fc5159abadb7ecf451d265505 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:56:48 +0200 Subject: [PATCH] repo: publish the host regression suite; consolidate .gitignore - tests/ (114 host cases over production sources), the CI workflow (ASan/UBSan on PRs and master pushes) and REGRESSION_TEST_PLAN.md are tracked - .gitignore grouped and deduplicated (173 -> 96 lines, same ignored set) --- .github/workflows/tests.yml | 32 + .gitignore | 192 ++---- docs/REGRESSION_TEST_PLAN.md | 732 ++++++++++++++++++++++ tests/CMakeLists.txt | 60 ++ tests/README.md | 158 +++++ tests/catalog.json | 526 ++++++++++++++++ tests/harness_tests/test_runner.py | 78 +++ tests/run.py | 264 ++++++++ tests/support/dispatcher_fixture.h | 101 +++ tests/support/hash_capture.cpp | 18 + tests/support/hash_capture.h | 8 + tests/support/identity_utils_boundary.cpp | 41 ++ tests/support/zephyr/kernel.h | 4 + tests/support/zephyr/logging/log.h | 13 + tests/support/zephyr/random/random.h | 4 + tests/support/zephyr/sys/util.h | 5 + tests/unit/advert_tests.cpp | 42 ++ tests/unit/contention_tests.cpp | 48 ++ tests/unit/dispatcher_tests.cpp | 188 ++++++ tests/unit/helper_tests.cpp | 58 ++ tests/unit/identity_tests.cpp | 85 +++ tests/unit/lora_timing_tests.cpp | 45 ++ tests/unit/lpp_tests.cpp | 50 ++ tests/unit/main.cpp | 24 + tests/unit/packet_tests.cpp | 115 ++++ tests/unit/policy_tests.cpp | 49 ++ tests/unit/pool_tests.cpp | 65 ++ tests/unit/prefs_codec_tests.cpp | 299 +++++++++ tests/unit/prefs_json_tests.cpp | 193 ++++++ tests/unit/radio_tests.cpp | 200 ++++++ tests/unit/telemetry_text_tests.cpp | 45 ++ tests/unit/test.h | 18 + tests/unit/timesync_tests.cpp | 207 ++++++ 33 files changed, 3830 insertions(+), 137 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 docs/REGRESSION_TEST_PLAN.md create mode 100644 tests/CMakeLists.txt create mode 100644 tests/README.md create mode 100644 tests/catalog.json create mode 100644 tests/harness_tests/test_runner.py create mode 100644 tests/run.py create mode 100644 tests/support/dispatcher_fixture.h create mode 100644 tests/support/hash_capture.cpp create mode 100644 tests/support/hash_capture.h create mode 100644 tests/support/identity_utils_boundary.cpp create mode 100644 tests/support/zephyr/kernel.h create mode 100644 tests/support/zephyr/logging/log.h create mode 100644 tests/support/zephyr/random/random.h create mode 100644 tests/support/zephyr/sys/util.h create mode 100644 tests/unit/advert_tests.cpp create mode 100644 tests/unit/contention_tests.cpp create mode 100644 tests/unit/dispatcher_tests.cpp create mode 100644 tests/unit/helper_tests.cpp create mode 100644 tests/unit/identity_tests.cpp create mode 100644 tests/unit/lora_timing_tests.cpp create mode 100644 tests/unit/lpp_tests.cpp create mode 100644 tests/unit/main.cpp create mode 100644 tests/unit/packet_tests.cpp create mode 100644 tests/unit/policy_tests.cpp create mode 100644 tests/unit/pool_tests.cpp create mode 100644 tests/unit/prefs_codec_tests.cpp create mode 100644 tests/unit/prefs_json_tests.cpp create mode 100644 tests/unit/radio_tests.cpp create mode 100644 tests/unit/telemetry_text_tests.cpp create mode 100644 tests/unit/test.h create mode 100644 tests/unit/timesync_tests.cpp diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d90ebb2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: host regression tests + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + quick: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install host build tools + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build g++ + - name: Run real production code with ASan and UBSan + run: python tests/run.py run --profile quick --sanitize + - name: Retain results and diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: host-regression-results + path: test-results/ + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index b001d24..3d500c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Build output (west build creates these) +# ---- Build output ------------------------------------------------------------ /build*/ build/ build-*/ @@ -6,46 +6,58 @@ build-*/ **/build-*/ **/build_*/ /twister-out*/ -/build-tests/ /test-results/ /tests/lab.local.yaml +CMakeCache.txt +CMakeFiles/ +.ninja_* +cmake_install.cmake +Makefile +compile_commands.json -# Regression suite stays local until it is ready to publish. -/tests/ -/.github/workflows/tests.yml -/docs/REGRESSION_TEST_PLAN.md - -# West workspace (recreated by west init/update) +# ---- West workspace: projects `west update` fetches beside zephcore/ ----------- /.west/ - -# Zephyr SDK and toolchain (large, downloaded separately) -/bootloader/ -/doc/ +/zephyr/ /modules/ /tools/ -/zephyr/ +/doc/ +/bootloader/ -# Python -.venv/ -__pycache__/ -*.pyc +# ---- Local-only working docs -------------------------------------------------- +# devdocs/ holds the restructure plan, LLDs, audit indexes, handovers, trackers. +/devdocs/ +/westchanges/ +/meshtimesync-sim/ +CLAUDE.md +# Working docs dropped at the root by habit: matched by shape, not one by one +# (a per-file list once missed one and it went into a commit). +[Hh][Aa][Nn][Dd][Oo][Vv][Ee][Rr]_*.md +*_AUDIT_INDEX.md +*_HANDOFF.md +UPSTREAM_TRACKER.md +WEST_UPDATE.md -# Editors / IDE -.vscode/ -.idea/ -*.swp -*~ -*.bak +# Vendor reference docs (datasheets, errata): not ours to redistribute. +/datasheets/ -# Debug artifacts +# Release notes of versions released before the tracked set began. The notes +# file of the CURRENT version must stay tracked: the release workflow publishes +# it as the GitHub release body (body_path). +releasenotes/RELEASE_NOTES_1.16.3-zephcore.md +releasenotes/RELEASE_NOTES_1.16.4-zephcore.md + +# ---- Debug artifacts ---------------------------------------------------------- *.log +rtt_*.txt jlink_*.txt -rtt_*.log +*.jlink +*.dmp +core +*.tmp +*.orig -# UF2 / binary artifacts +# ---- Binaries ----------------------------------------------------------------- *.uf2 -!formatter/ -!formatter/*.uf2 *.hex *.bin *.elf @@ -54,120 +66,26 @@ rtt_*.log *.o *.a *.zip - -# ...except the prebuilt formatter DFU packages. These are shipped artifacts, not -# build output: build.sh publishes them as the Mesh America configurator's `erase` -# packages (see formatter/README.md, PROVIDER_CATALOG.md), so they must be tracked. +# ...except shipped artifacts: the SoftDevice formatter DFU packages (build.sh +# publishes them as the configurator's `erase` packages, formatter/README.md) and +# the prebuilt LR1110 radio-firmware updaters (LR1110_updater/README.md). +!formatter/ +!formatter/*.uf2 !formatter/*.zip - -# ...and the prebuilt LR1110 radio-firmware updaters. Also shipped artifacts: -# updating an LR1110 needs Semtech's encrypted images compiled in, so handing -# someone a prebuilt binary is far more practical than asking them to build the -# tool. See LR1110_updater/README.md. !LR1110_updater/ !LR1110_updater/*.uf2 !LR1110_updater/*.bin -# OS junk +# ---- Tooling ------------------------------------------------------------------ +.venv/ +__pycache__/ +*.pyc +.claude/ +.vscode/ +.idea/ +*.swp +*~ +*.bak .DS_Store Thumbs.db desktop.ini - -# Claude Code project data -.claude/ - -# RTT output files (missed by *.log pattern) -rtt_*.txt - -# JLink scripts and outputs -*.jlink - -# Internal working docs: per-topic audit indexes, handovers, handoffs, and -# other local-only scratch notes (see the HANDOVER_*/​*_AUDIT_INDEX rules below, -# kept for pre-2026-08-24 history/muscle memory even though this covers them). -/devdocs/ - -# Temporary/scratch files -*.tmp -*.orig - -# CMake/Ninja build system -CMakeCache.txt -CMakeFiles/ -.ninja_* -cmake_install.cmake -Makefile -compile_commands.json - -# Memory dumps -*.dmp -core - -# Utility/test source files -meshcore/src/eraser_main.cpp -zephcore/AUDIT_FINDINGS.md - -# Upstream tracking (local only) -UPSTREAM_TRACKER.md -WEST_UPDATE.md -/westchanges/ - -# Side projects (not part of main firmware) -/doom/ -THINKNODE_M1_HANDOVER.md -CLAUDE.md -# Working docs at the workspace root are local-only: per-topic audit indexes, -# handovers and handoffs. These were listed one file at a time, which meant -# every new one had to be remembered — and one was not, so it went into a commit -# before anybody noticed. Match by shape instead; the individual lines below are -# now redundant but harmless. -[Hh][Aa][Nn][Dd][Oo][Vv][Ee][Rr]_*.md -*_AUDIT_INDEX.md -*_AUDIT_HANDOFF.md -*_HANDOFF.md - -zephcore/apc_checklist.md -RADIO_AUDIT_INDEX.md -RX_BUSY_LATCH_PLAN.md -zephcore/SX126X_LBT_RX_DEBUG_HANDOFF.md -PSRAM_HANDOFF.md -BLE_AUDIT_INDEX.md -POWER_AUDIT_HANDOFF.md -POWER_AUDIT_INDEX.md -CRYPTO_AUDIT_HANDOFF.md -CRYPTO_AUDIT_INDEX.md -AUDIT_MASTER_PLAN.md -usb_companion_probe.py -CORE_MESH_AUDIT_INDEX.md -UPLINK_AUDIT_INDEX.md -GPS_AUDIT_INDEX.md -UI_AUDIT_INDEX.md -RADIO2_AUDIT_INDEX.md -MISC_AUDIT_INDEX.md -HANDOVER_issue34_ble_esp32.md -HANDOVER_crypto_hw_accel.md -PATH_HASH_AUDIT_INDEX.md -MESHTIMESYNC_PLAN.md -/meshtimesync-sim/ - -# Older release notes kept local-only. Renamed 2026-07-16 to the -# RELEASE_NOTES_-zephcore.md convention; these rules follow the rename. -# NOTE: the notes file for the CURRENT version must stay TRACKED — the release -# workflow publishes it as the GitHub release body (body_path). Only add a rule -# here for versions that are already released. -releasenotes/RELEASE_NOTES_1.16.3-zephcore.md -releasenotes/RELEASE_NOTES_1.16.4-zephcore.md -handover_thinknode_m9.md - -# Vendor reference docs (datasheets, errata). Local only — not ours to redistribute. -/datasheets/ -LR2021_AUDIT_INDEX.md -HANDOVER_lr2021_audit.md - -# LR2021 bring-up scratch: the CAD_LBT revert patch and its measurement notes. -# Local only, like the audit index and handover above. Ignore the whole -# directory — per-file rules were tried and could not work, because .gitignore -# does not apply to paths already in the index (three of these went into -# b839c2c for exactly that reason; untrack with `git rm -r --cached`). -lr2021_bench/ -LR2021_MATH_AUDIT.md diff --git a/docs/REGRESSION_TEST_PLAN.md b/docs/REGRESSION_TEST_PLAN.md new file mode 100644 index 0000000..d02524c --- /dev/null +++ b/docs/REGRESSION_TEST_PLAN.md @@ -0,0 +1,732 @@ +# ZephCore repeatable regression suite + +Status: implementation roadmap, based on source at `1aa629c` (2026-09-21). +A first hardware-free subset now exists in [tests/README.md](../tests/README.md): +`quick`, exact `--case` selection, 45 native C++ cases, 10 harness self-tests, +JSON/JUnit reporting and Linux sanitizer CI. It uses a JSON catalog and standalone +host CMake target; Zephyr-dependent layers still require separate integration targets. +Only the commands and coverage documented in that README are implemented. The broader +profiles, options, fixtures and applications below remain planned. No devices were +flashed or exercised during this implementation. + +Implemented follow-up: Dispatcher scheduling, RX parsing and recovery cases plus +real Monocypher-backed identity/signature/storage tests. The new truncated-header +case reproduced an out-of-bounds read under AddressSanitizer; Dispatcher now checks +the received extent before reading route header fields. Full milestone completion, +kernel integration, application roles and hardware profiles remain outstanding. + +## Objective + +After a change, run the same version-controlled cases against an identified build, +produce comparable evidence, and identify precisely what passed, failed, or was not +tested. Start with radio RX/TX and packet handling; grow into all firmware roles and +subsystems. No finite suite proves that every future change is safe. + +The deliverable is a regression package with unit, integration, hardware-in-the-loop +(HIL), interoperability, and endurance tests. Most edge cases should run without +boards. Hardware tests verify the driver, timing, and RF behavior that mocks cannot. + +## Existing foundations and gaps + +- `zephcore/src/Packet.cpp`: serialization, bounds, path encoding, packet hashes. +- `Dispatcher.cpp`, `Mesh.cpp`, `StaticPoolPacketManager.cpp`, `ContentionTracker.cpp`: + scheduling, routing, ownership, deduplication, contention and airtime accounting. +- `zephcore/include/mesh/Radio.h`, `Clock.h`, `RNG.h`, `Dispatcher.h`: existing seams + for fake radio, time, randomness, and packet management. +- `zephcore/adapters/radio/LoRaRadioBase.*`: shared radio state; chip-specific + `hw*()` methods provide a useful seam, but the base still needs a Zephyr kernel. +- `zephcore/patches/`: radio behavior also depends on patched Zephyr/module drivers. +- `zephcore/app/` and `helpers/`: companion, repeater, room, observer, storage, + transport, routing policies and time synchronization all need their own coverage. +- `tests/` holds the host suite (published 2026-09-25); `.github/workflows/tests.yml` + runs it with ASan and UBSan on pull requests and pushes to master, separate from + the firmware build and release workflow. +- The Linux native application uses real SPI/GPIO hardware. It is not automatically + a simulated radio network; a fake-radio test target must be added separately. + +## Test layers and profiles + +| Layer | Runs against | Main purpose | Proposed profile | +|---|---|---|---| +| Unit | Actual production C/C++ with controlled dependencies | Exhaustive boundaries, exact state transitions | `quick` | +| Zephyr integration | Test applications on `native_sim` | Queues, timers, events, transport, shared radio base | `integration` | +| Build | Board/role/feature configurations | Compile, link, memory and packaging regressions | `build` | +| HIL smoke | Four physical boards | Boot, pairwise RX/TX, messages, recovery | `bench-smoke` | +| HIL extended | Boards with role/preset rotation | Radio matrix, routing, failures, persistence | `bench-full` | +| Endurance | Fixed bench scenario | Leaks, stalls, drift, recovery over hours | `soak` | +| Release | Above plus reference firmware and release images | Compatibility and packaging qualification | `release` | + +Target budgets after build caching: quick under 2 minutes, integration under 10, +smoke 10–20 minutes, full 1–3 hours, soak 8–24 hours. These are design budgets, +not measurements; slow modem presets and duty-cycle limits may require longer. +Estimate airtime and duration before each hardware run. + +Use Ztest/Twister for C/C++ and Zephyr tests, and pytest for bench orchestration, +parameterization and reports. Initially keep the multi-board bench orchestration +in pytest so it can control mixed firmware roles and flashing methods explicitly. +Validate all tool options against the Zephyr revision pinned in `west.yml` before +implementation; current online documentation can describe newer features. + +Use Linux CI/devcontainer for native simulation and sanitizers. Run bench Python +on Windows where the serial ports and debug probes are connected; exchange builds +through an artifact manifest. Avoid requiring Windows USB passthrough into Linux. + +## Hardware-free coverage is the main suite + +Most behavioral regression cases should execute without connected boards, including +cases inside large application `.cpp` files. Physical hardware is needed to validate +the boundary, not every decision behind it. A Zephyr dependency does not itself +imply a hardware dependency: kernel-backed tests can run in native simulation. + +The table is a source-level implementation backlog, not a claim that these files +already compile standalone. Include chains, global symbols and configured features +must be resolved as each target is brought up. Paths are relative to `zephcore/`. + +| Production source / group | Hardware-free behaviors to test | Required environment or seam | Order | +|---|---|---|---| +| `src/Packet.cpp` | Decode/encode, lengths, path copies, headers, hash semantics | Real hash backend for golden hash cases; no radio | 1 | +| `src/StaticPoolPacketManager.cpp` | Capacity, ownership, priority, scheduling, rescheduling | Controlled timestamps; real pool | 1 | +| `src/ContentionTracker.cpp` | Samples, warmup, eviction, decay, caps and overflow boundaries | Supplied timestamps and packet fixtures | 1 | +| `helpers/AdvertDataHelpers.cpp` | Flags, coordinates, names, UTF-8 truncation, malformed input | Byte fixtures; no device | 1 | +| `src/Identity.cpp` | Seed expansion, persisted expanded keys, signing, verification, shared secrets | Bundled real Monocypher; deterministic test RNG where needed | 1 | +| `src/Utils.cpp` | Hex/conversion helpers and crypto wrappers, failure handling | Software PSA backend; injected PSA errors/reboot capture in separate fault tests | 1 | +| `src/Dispatcher.cpp` | Queue dispatch, CAD/LBT timing, TX failure, budgets, counters, maintenance deadlines | Fake Radio/clock and actual packet manager; small platform shims or native_sim | 1 | +| `src/Mesh.cpp` | Routing, dispatch, dedup, packet construction, decrypt/verify, response decisions | Deterministic Radio/clock/RNG; real packet/crypto code; capture callbacks | 2 | +| `helpers/BaseChatMesh.cpp` | Contacts, advert updates, route selection, pending ACKs and retry state | Mesh fixture, fake time and captured delivery callbacks | 2 | +| `helpers/ClientACL.cpp` | Client lookup/update, capacity, permissions, save/load validation | Real identity code; filesystem fake or native filesystem fixture | 2 | +| `helpers/TransportKeyStore.cpp` | Derivation, transport codes, cache replacement and key lookup | Real PSA crypto; controlled key storage | 2 | +| `helpers/RegionMap.cpp` | Region names/hierarchy, scope decisions, defaults, removal, persistence | Real key-store behavior; filesystem fixture | 2 | +| `helpers/MeshTimeSync.cpp` | Quorum, tenure, stale samples, inconsistent voters, bootstrap, suppression, forward-only steps | Controlled uptime/RTC and clock-set hooks; no real waiting or GPS | 2 | +| `app/CompanionMesh.cpp` | Command parsing, response bytes, contacts/channels, offline queue, message send/receive, authorization | Native integration fixture with storage, board, BLE-output, GPS/sensor and reboot boundaries substituted | 3 | +| `app/RepeaterMesh.cpp` | Forward/drop decisions, ACL, neighbor state, requests, rate limits, deadlines | Real Mesh plus fake external services and captured transmissions | 3 | +| `app/RoomServerMesh.cpp` | Login, permissions, posts, history limits, fanout, retry and slow-client behavior | Multiple simulated clients, fake time/storage, captured packets | 3 | +| `app/ObserverMesh.cpp` | Receive processing, publication content, filtering, no LoRa TX | Recording publisher and fake radio | 3 | +| `helpers/CommonCLI.cpp`, `app/*RegionCLI.cpp` | Parsing, numeric limits, invalid arguments, preference changes, replies, deferred effects | Capture board/radio/settings side effects; native kernel for work items | 3 | +| `app/RepeaterUplink.cpp` | Command validation, JSON content, status and publish decisions | Recording WiFi/MQTT boundary; no network account | 3 | +| `app/RepeaterDataStore.cpp`, `adapters/datastore/ZephyrDataStore.cpp`, `app/observer_creds.cpp` | Serialization, migration, atomic replacement, truncation, short writes, full filesystem | Native filesystem/flash simulation plus explicit error injection | 3 | +| `adapters/radio/LoRaRadioBase.cpp` | State transitions, config/clamps, counters, CAD adaptation, recovery and maintenance | Real Zephyr kernel with fake `hw*()` primitives; event scheduling control | 3 | +| `adapters/radio/{SX126x,LR1110,LR2021,SX127x}Radio.cpp` | Driver argument mapping, feature support, error propagation | Fake driver API/SPI boundary; hardware still validates actual chip behavior | 4 | +| `adapters/usb/*.cpp`, `adapters/ble/ZephyrBLE.cpp` | Framing/queues, congestion, lifecycle and callback ordering | Native kernel, simulated connection and transport callbacks | 4 | +| `adapters/gps/ZephyrGPSManager.cpp` | Fix acquisition, timeout, duty policy, invalid/stale readings | Fake GNSS events, uptime, power-control calls | 4 | +| `adapters/clock/*.cpp`, `adapters/rng/*.cpp`, `adapters/sensors/ZephyrEnvSensors.cpp` | Conversion, fallback, missing-device and error behavior | Fake device API; actual software algorithms retained | 4 | +| `helpers/ui-joystick/`, `helpers/ui/ui_mesh_actions.cpp` | Input sequences, state transitions, selected actions and text bounds | Recorded input/render/action interfaces; visual and electrical checks remain separate | 4 | +| `src/main_*.cpp`, `app/main_observer.cpp`, `adapters/board/ZephyrBoard.cpp` | Initialization failure, event dispatch, sleep/wake decisions, reboot requests | Selected native integration scenarios; substantial board glue remains build/HIL tested | 4 | + +Header-only and C code must be included too: `RoutingPolicy.h`, `RateLimiter.h`, +`SimpleMeshTables.h`, `UTF8Helpers.h`, `TxtDataHelpers.h`, `MeshcoreJson.h`, +`compat/CayenneLPP.h` (covered: UNIT-LPP-001..004), battery conversion, shared companion framing and transport C files. +For example, exhaust every reply-route Boolean combination and flood-limit boundary; +exercise the rate limiter after prolonged denials, counter saturation, timestamp +wrap and reset. These cases need no physical devices and can catch real defects. + +### How to test the large classes + +Keep the production class as the system under test. Construct a fixture around it: + +```text +scripted incoming bytes/commands → actual CompanionMesh / RepeaterMesh / RoomServerMesh + ↕ real Mesh, Packet, queues, crypto +fake clock + seeded RNG + storage → fixture → recorded outgoing bytes and state +``` + +Assertions describe observable behavior: exact response bytes, intended recipient, +number of transmissions, stored settings, delivered messages and ownership after +completion. Avoid a test that merely asserts each internal helper was called. +Use existing virtual interfaces first. For direct global/device calls, introduce +small service boundaries or link-time fakes with documented contracts; prefer +native Zephyr integration when emulating the kernel would become a second project. +Do not fake all of storage and then claim to test its real persistence implementation. + +Examples of executable hardware-free scenarios: + +1. Feed a channel-message command into CompanionMesh; inspect its actual emitted + packet; deliver it to a second real mesh instance with the same channel; assert + one matching app event. Repeat with a wrong key and a truncated body. +2. Let RepeaterMesh receive a flood at each configured hop boundary; assert the + correct forwarding decision and path bytes, including region policy. +3. Let RoomServerMesh accept clients, post a message, lose an ACK and advance fake + time; verify fanout/retry state without sleeping or using a radio. +4. Feed MeshTimeSync days of synthetic signed-advert metadata in milliseconds of + test runtime; verify quorum and suppression boundaries. Separately test that + callers never feed unverified adverts into the estimator. +5. Drive real datastore code through short writes and failed rename operations; + reopen the same simulated volume and verify the documented recovery contract. +6. Feed fragmented companion frames through the real parser, interleave unrelated + push events, and verify command responses survive queue pressure. + +### Coverage accounting and implementation order + +Maintain a tracked inventory mapping every owned production `.cpp`, relevant `.c` +and logic-bearing header to test targets, covered behaviors, missing behaviors and +remaining hardware-only checks. New unclassified files fail an inventory check. +An intentional exclusion needs a reason; vendor libraries are tracked separately +from owned code and tested through integration where used. A file merely linked +into a test binary does not count as behavior covered. + +Deliver orders 1 and 2 as the first software track. Build the board smoke harness +as a separate track, not a prerequisite for application-logic tests. Then add order +3 application/service fixtures and order 4 boundary integrations. Measure function +and branch coverage per module alongside requirement coverage; the first goal is +to expose missing behaviors, not to meet an arbitrary aggregate percentage. + +## Standard unit-test checklist for each module + +Apply this checklist to each owned module in the coverage inventory. Mark a category +covered, planned or non-applicable with a reason; do not manufacture meaningless +tests simply to fill every category. Test public contracts and observable effects, +including documented internal invariants where resource safety depends on them. + +| Category | Required questions and useful cases | +|---|---| +| Initialization | Are defaults valid? What happens before initialization, after failed initialization and after repeated initialization where supported? Are all readable fields initialized? | +| Normal behavior | At least one independently specified example for each meaningful operation and decision outcome; verify outputs and side effects together. | +| Boundaries | Empty, one element, typical, capacity minus one, capacity and one over; just before/at/after every threshold; minimum/maximum supported configuration. | +| Invalid input | Truncation, unsupported enum/opcode, inconsistent length/count, invalid flags, malformed text and invalid numeric input; no unauthorized or unintended state change. | +| Failure behavior | Each external operation fails in turn; errors propagate according to contract; partial work is cleaned up; subsequent valid operations succeed. | +| State transitions | Legal transitions, forbidden transitions, duplicate operations, cancellation, timeout, reset and reuse; stale events from an earlier operation. | +| Ownership | Every acquired resource has exactly one terminal owner/release; no premature release, leaks, double completion or overwrite of queued data. | +| Persistence | Supported old fixtures load; updates preserve unrelated fields; interrupted operations leave a documented recoverable state. | +| Isolation | One instance/client/channel does not corrupt another; each test begins with known state; test order does not change results. | +| Configuration | Relevant role, feature and capacity variants execute the behavior, rather than merely compiling. | + +Null pointers, overlapping buffers and invalid object states should be tested only +where the API promises to handle them or an untrusted entry point can produce them. +Do not call undefined C/C++ operations and demand graceful recovery. Document trusted +caller preconditions and test that production callers enforce them. Invalid external +input must be rejected before reaching a helper with stricter preconditions. + +### Numeric, memory and representation boundaries + +- NUM-001: signed/unsigned comparisons, narrowing lengths, negative intermediate + values, multiplication before bounds checks, integer rounding and saturation. + Test time/counter arithmetic around zero and the largest representable values. +- NUM-002: millisecond wraparound and intervals at the supported scheduling horizon; + distinguish wrapping monotonic counters from adjustable wall-clock timestamps. + State the maximum valid interval for any wrap-safe comparison algorithm. +- NUM-003: where floating-point input is accepted, test NaN, infinity, negative zero, + out-of-range values and rounding near valid boundaries. Specify tolerances from + the physical/protocol requirement; never choose them after seeing a failure. +- MEM-001: exact-size input/output buffers and guard bytes; truncated variable-size + fields; no out-of-range access. Verify required output termination and capacity + handling without imposing string rules on binary buffers. +- MEM-002: allocate/free/reuse packet and contact slots with distinct byte patterns; + ensure stale lengths, metadata, paths or flags cannot affect the next operation. + Check sensitive-data clearing only where the lifecycle contract requires it. +- REP-001: explicit wire byte order, unaligned input addresses, serialization field + widths and signed fixed-point conversion. Do not serialize native struct padding. + Native simulation may use different `long`, pointer and alignment sizes from MCUs; + retain cross-target builds and selected on-target tests for architecture differences. + +Use address/undefined-behavior sanitizers on supported host test targets, with bounds +and guard assertions providing additional evidence. Sanitizer availability varies +by target; record which instrumentation actually ran. Sanitizers do not establish +correct scheduling, physical timing or complete absence of memory defects. + +### Failure atomicity and recovery + +FAIL-001: snapshot observable state before a malformed or rejected command. Verify +that only documented effects, such as an error counter, changed; contacts, settings, +identity and queued traffic remain consistent. Parsing failures need not preserve +every temporary parser field unless its API promises that behavior. + +FAIL-002: inject a failure at each meaningful stage of an operation: allocation, +read/open/write/sync/rename, crypto setup/update/finalization, enqueue and driver +submission/completion. Include short successful reads/writes, not just negative +return codes. Assert cleanup and recovery using the actual production error path. + +FAIL-003: after each failure, run a valid operation and verify useful progress. A +correct error response followed by permanent deafness or a leaked pool is a failure. +For multi-step persistence, specify allowed old/new states and recovery behavior; +do not demand atomicity that the actual storage contract does not provide. + +FAIL-004: distinguish accepted, queued, transmitted, acknowledged and delivered. +A send-call success is not an application delivery assertion; a driver rejection +must not become a successful-transmit statistic. Verify counters by independent +event accounting at stable checkpoints, not by reading the same counter twice. + +### Ordering, lifecycle and concurrency + +SEQ-001: exercise timeout/completion/cancellation at the same logical deadline, +completion after reset, duplicate completion, disconnect with pending output, and +reconnect with stale buffered input. Check terminal outcomes and resource ownership. + +SEQ-002: run bounded deterministic sequences against real production state machines: +receive → enqueue → busy → retry → completion; enqueue → reset → late completion; +receive → overflow → drain → receive. Maintain a small independent state model and +compare externally observable results after every step. + +SEQ-003: for code that actually runs across threads/ISRs, use controlled barriers +and event injection in kernel integration tests to force relevant interleavings. +Check missed wakeups, publication before initialization, queue draining races and +shutdown with pending work. Test ISR constraints through appropriate driver/kernel +fixtures. A single-threaded fake cannot prove thread safety, and a random stress run +cannot replace explicit interleaving cases. + +SEQ-004: multiple instances, contacts and channels with similar identifiers; duplicate +requests; repeated start/stop/reset where supported; repeated errors beyond counter +capacity. Assert isolation and documented idempotency rather than assuming every +operation is idempotent. + +## Properties, generated cases and independent oracles + +Example-based cases remain the readable specification. Supplement them with bounded, +seeded input generation against the same production code. Save the seed, exact input +or operation sequence and minimized failing case; a seed alone may cease reproducing +after a generator version changes. Keep generator versions in the run metadata. + +| Property family | Examples and limits | +|---|---| +| Serialization | Valid representable packets preserve semantic fields through encode/decode; pair this with independent golden bytes to avoid a shared encoder/decoder bug. | +| Parser safety | Arbitrary bounded bytes do not crash, access invalid memory or trigger unintended actions; accepted inputs satisfy the parser's documented invariants. | +| Resource conservation | At stable checkpoints, free + queued + held + in-flight equals pool capacity; no packet belongs to two ownership states. | +| Bounded policy | Jitter, delay, output power, queue depth and path size stay within their specified bounds; test special cases where a cap or override intentionally changes behavior. | +| Stream partitioning | A valid frame produces the same command when split at any byte boundary or coalesced with other frames, provided timeouts are not crossed. | +| Isolation | Adding an unrelated contact/channel or processing unrelated traffic does not alter the operation's result except for explicitly shared capacity or scheduling effects. | +| Persistence | Save/reload preserves supported semantic fields; migrations match independently authored old-version fixtures. | +| Routing truth tables | All Boolean policy combinations and threshold-adjacent hop counts agree with a simple independently reviewed policy table. | + +Use exhaustive enumeration for small spaces, table-driven cases for known boundaries, +and bounded generated sequences for large spaces. Quick CI has fixed seeds and small +limits; extended runs rotate recorded seeds and larger limits. Shrinking must retain +the relevant preconditions so the reduced failure still represents a valid finding. + +Golden fixtures include provenance, protocol/storage version, expected fields and +why the vector is valid or invalid. Never auto-update expected results in a normal +test run. Differential tests may compare a pinned reference implementation, but +document intentional differences and use independent vectors: agreement alone is +not proof that either implementation is correct. + +## Configuration and cross-feature tests + +CONFIG-001: run logical capacity tests at the smallest supported setting and a +representative production setting; zero is included only when supported. Ensure +compile-time limits and runtime bounds agree for contacts, channels, queues and pools. + +CONFIG-002: test feature-off paths, unavailable optional devices and role differences. +An unsupported request gets its specified response without accessing disabled +services. Build-only checks and executed behavioral checks have separate statuses. + +CONFIG-003: include these high-value interactions in native integration fixtures: + +- Clear statistics while TX is pending; subsequent completion is accounted for + according to the reset contract without corrupting control state. +- Save preferences during a temporary radio override; stored and active settings + retain their intended meanings, including restore after reboot. +- Remove a contact/channel while related work is queued; no dangling reference or + unintended delivery, with cancellation behavior explicitly specified. +- Adjust wall time while retries, rate limits and maintenance are scheduled. +- Disconnect a companion while lossless responses and lossy pushes compete for space. +- Exhaust a shared packet pool while a recovery/admin operation needs resources. + +Start with explicit risk-driven combinations. A versioned pairwise matrix broadens +coverage but does not replace known three-way or higher-order interactions. + +## Test reliability and harness self-tests + +These are tests of the regression package, distinct from firmware tests. Use tiny +dummy executables, recorded transport streams and fake discovery/flashing services +so harness failures can themselves be reproduced without attached boards. + +| ID | Injected harness condition | Required outcome | +|---|---|---| +| HARNESS-001 | Empty collection, unmatched case selector, missing mandatory implementation | Nonzero incomplete/error result; never a successful zero-test run | +| HARNESS-002 | Child failure, crash, hang or malformed/truncated report | Nonzero result, bounded timeout, retained stdout/stderr and failure phase | +| HARNESS-003 | Wrong firmware hash/board, duplicate device identity or absent capability | Reject assignment before dependent tests; record exact mismatch | +| HARNESS-004 | Old success text in a log; packet from a prior session | Ignore as evidence for the current run using session/correlation IDs | +| HARNESS-005 | Port disappears/re-enumerates; probe/port already locked | Bounded recovery or infrastructure error; no accidental reassignment | +| HARNESS-006 | Setup or teardown fails; runner interrupted | Original failure retained, cleanup status reported, locks released where possible | +| HARNESS-007 | Full output disk, invalid profile or conflicting artifact paths | Explicit failure; no overwritten baseline or incomplete report presented as complete | +| HARNESS-008 | Duplicate case IDs, stale catalog mapping or missing expected case results | Inventory/report validation fails; every selected case accounted for | +| HARNESS-009 | Diagnostic retry succeeds after initial failure | Both attempts preserved; original qualification result not silently changed | + +Fixtures reset mutable globals, fake time/RNG, queues and filesystem contents between +tests. Run selected suites in reversed/shuffled order with a recorded seed to expose +dependencies; repeat a case in the same process to expose leaked state. Ordinary unit +tests must not depend on Internet access, wall-clock sleeps, local timezone, personal +files or connected devices. Bound loops, generated inputs, waits and subprocesses. + +Fakes should fail on unexpected external calls, record inputs and allow explicit +failure scripting. Keep fake service contracts small and test them against the real +boundary where practical. Avoid asserting incidental call order unless the order is +part of correctness. Prefer value/result assertions over large opaque snapshots. + +Measure assertions as well as coverage: selectively mutate boundary comparisons, +remove a validation check, suppress a required wakeup or alter a routing decision in +an isolated checkout and verify the relevant tests fail. Surviving mutations require +review; equivalent mutations and unreachable configurations need an explanation, +not artificial tests. Run targeted mutation checks periodically rather than making +every edit wait for the full mutation workload. + +Flaky cases remain visible with their first failure evidence. Temporary quarantine +requires an owner, issue and expiry and reduces the reported qualified scope; it +does not turn a mandatory failing case into a green release gate. + +## Proposed package and command contract + +```text +tests/ + README.md + run.py # one entry point; delegates to existing tools + requirements.txt # pinned test dependencies + catalog.yaml # stable case IDs, requirements and implementation mapping + profiles/ # explicit case selections and acceptance limits + lab.example.yaml # identities/capabilities, no personal keys + unit/ # production sources linked into small test targets + integration/ # Zephyr native_sim applications and fakes + hil/ # pytest test cases + harness/ # discovery, transport, flashing, capture, reports + fixtures/ # golden packets, corrupt inputs, saved-state versions + fuzz/ # parser and state-machine fuzz targets/corpora + regressions/ # minimized reproductions of specific fixed bugs + harness_tests/ # runner/report/discovery failures without boards +``` + +Do not put this in root `tools/`: that directory is ignored as part of the west +workspace. Keep personal lab maps and generated results ignored separately. + +Proposed interface, to implement in milestone 1: + +```text +python tests/run.py list --profile quick +python tests/run.py run --profile quick +python tests/run.py doctor --lab tests/lab.local.yaml +python tests/run.py run --profile bench-smoke --lab tests/lab.local.yaml --firmware artifacts/build.json +python tests/run.py run --profile bench-full --lab tests/lab.local.yaml --firmware artifacts/build.json +python tests/run.py run --profile soak --lab tests/lab.local.yaml --duration 8h +python tests/run.py run --case RADIO-004 --lab tests/lab.local.yaml --seed 12345 +``` + +`list` expands the exact cases and parameters without side effects. `doctor` checks +device identities, probes, transports, firmware capabilities and required equipment. +`run` owns setup, execution, teardown and reporting. Planned/unimplemented cases +must never count as executed passes. A requested profile missing mandatory cases +or equipment exits nonzero and reports incomplete coverage. + +## Repeatability contract + +Every implemented case has: stable ID, requirement, layer, applicable roles/chips, +fixture version, setup, exact stimulus, independent oracle, deadline, cleanup, +required observability, and implementation path. Preserve IDs when refactoring. + +Each run records: + +- Git commit plus dirty diff/hash; west manifest and module revisions; patch hashes. +- Toolchain/test dependency versions; firmware SHA-256; resolved `.config` and DTS; + build command, board, role, bootloader layout and test-hook status. +- Physical device identities and radio firmware revisions where queryable; actual + control/log transport; RF preset, power, antenna/attenuation arrangement. +- Case/catalog/profile versions, seed, selected parameters, monotonic event times, + boot/session IDs and explicit packet correlation identifiers. +- Raw serial/BLE/RTT logs, transmitted/received bytes, counter snapshots, reset + reasons, JUnit XML, JSON measurements and a short human-readable summary. + +Statuses: PASS, FAIL, ERROR (infrastructure), SKIP (declared non-applicable), +BLOCKED (missing prerequisite), and NOT_IMPLEMENTED. Report selected, executed, +passed, failed and uncovered cases separately. A required SKIP is not a green gate. +An optional expected failure needs a linked issue and expiry; an unexpected pass +must prompt review. Never silently retry failures until green. Diagnostic retries +retain the first result and all attempts. + +Pin baseline firmware and retain its artifacts. Use identical cases, fixtures and +RF arrangements for comparisons. Test candidate-to-candidate, candidate-to-baseline +and baseline-to-candidate; two candidates can share a bug and still communicate. +Freeze acceptance limits before a run; changing a limit is a reviewed test change. + +## Four-board laboratory + +Starting assumptions, to verify against the actual connected hardware: + +| Logical ID | Board target | MCU / radio | Purpose | +|---|---|---|---| +| wio | `wio_tracker_l1` | nRF52840 / SX1262 | Established SX1262 peer | +| t1000 | `t1000_e` | nRF52840 / LR1110 | Cross-radio peer | +| nrf54 | `xiao_nrf54l15/nrf54l15/cpuapp` | nRF54L15 / external Wio SX1262 | Different MCU/timing | +| esp32 | `xiao_esp32s3/esp32s3/procpu` | ESP32-S3 / SX1262 assumed | Different scheduler/USB/BLE | + +The exact Wio variant and ESP32 radio attachment must be captured in the lab map. +Use USB serial numbers/probe IDs/BLE identity, not hardcoded COM numbers. Re-resolve +ports after reboot and bootloader entry. nRF54 has no native USB: describe its SWD +probe and UART/RTT/BLE control path explicitly. A USB cable is not proof of an +available companion-protocol endpoint. + +Each lab entry declares flash method, recovery procedure, available transports, +power-control capability, supported presets/features, and stored-state policy. +Acquire exclusive device locks. Restore settings in `finally` cleanup, verify them, +and record recovery instructions if interrupted. Use dedicated test identities and +channels; never log personal private keys or reset personal data as implicit setup. + +Exercise all 12 directed links between four devices; keep other nodes passive so +forwarding cannot masquerade as direct RX. Rotate sender, receiver and relay roles. +The four-board rig does not physically cover SX127x, LR2021, STM32WL or MG24. +Their build/software checks remain useful, but hardware coverage must say unavailable. + +Use a fixed RF arrangement and low suitable power. High packet delivery on a desk +does not measure sensitivity, calibrated output power, or range. Those require +attenuation/shielding and appropriate RF equipment. Do not directly connect radio +ports without a designed attenuation/power budget. Keep test traffic within the +lab's configured permitted band and airtime budget. + +## Radio RX/TX case families + +All cases below start as planned. Each family expands into individually named, +parameterized cases; HIL and simulated cases have separate results. + +| ID | Stimulus | Required observations / assertions | Layer | +|---|---|---|---| +| RADIO-001 | Boot, idle, first RX and first TX | Correct preset; receiver armed; no unexplained reboot | HIL | +| RADIO-002 | All 12 directed links, fixed numbered payloads | Byte-exact receive; missing/duplicate sequences; latency; counter deltas | HIL | +| RADIO-003 | Raw lengths 1, 2, boundary lengths through 255; invalid sizes through API seam | Supported raw frames intact; invalid lengths rejected without buffer damage | Both | +| RADIO-004 | Repeated RX → TX → RX; immediate inbound packet after TX | Exactly one TX completion; RX rearmed within a specified preset-dependent bound | Both | +| RADIO-005 | Back-to-back RX and bursts around ring capacity | Defined drop policy; overflow accounting; later packet succeeds; no stale metadata | Both | +| RADIO-006 | Async send failure, missing/late/duplicate completion, timeout | Bounded recovery; no double free/completion; subsequent TX/RX works | Integration, HIL subset | +| RADIO-007 | Preamble without payload, CRC/header error, aborted reception | Busy state clears; no phantom valid payload; next valid RX succeeds | Integration, RF fixture subset | +| RADIO-008 | CAD clear/busy, repeated driver LBT refusals, persistent busy | Correct deferral, independent streak timing, bounded escalation, no false TX count | Both | +| RADIO-009 | Supported frequency/BW/SF/CR/preamble/power combinations | Readback where available, peer interoperability, airtime within defined tolerance | Both | +| RADIO-010 | Change preset while idle/busy; temporary override and restore | Active/saved settings correct; no stale completion applied to new session | Both | +| RADIO-011 | SX126x RX duty cycle on/off across supported presets | Reception and recovery; unsupported radios reject/report unsupported | Both | +| RADIO-012 | Noise/CAD adaptation, starvation recovery, idle maintenance | Bounds, freeze/restore, deadlines and hysteresis; no permanent deaf state | Both | +| RADIO-013 | Reconfigure/send/receive/maintenance event order permutations | No deadlock, missed wake, use-after-free or stuck TX/RX state | Integration | +| RADIO-014 | Long simultaneous bidirectional load | Delivery distribution, queue high-water, memory trend and bounded recovery | HIL | +| RADIO-015 | Reset/disconnect one node during traffic | Remaining nodes live; reconnect/rejoin works; stale events ignored | HIL | +| RADIO-016 | Driver BUSY/SPI error/IRQ timeout injection | Error propagation, bounded recovery and counter attribution | Integration; fixture-dependent HIL | + +Separate raw radio diagnostics from application packet tests: a 255-byte radio +frame is not necessarily a valid MeshCore packet. Invalid physical CRC/header tests +need driver injection or a capable RF generator; a normal companion send command +cannot be assumed to generate them. + +Raw diagnostics should link the real adapter and patched driver in a small test +application. Also test ordinary production firmware end to end, since a diagnostic +image bypasses mesh scheduling and transport behavior. + +Start with one fixed preset on every directed pair. Extend with a versioned pairwise +configuration matrix, explicit minimum/maximum supported settings, and mandatory +chip-specific interactions. Exhaustive combinations are appropriate for small +software inputs; avoid an unbounded Cartesian product of every HIL dimension. + +## Packet and protocol coverage + +For every type below, specify behavior per role and route: decode, dispatch, forward, +respond, or intentionally ignore. Presence of a constant does not imply that every +role implements it. Test parser acceptance separately from semantic acceptance. + +| Value | Type | Semantic cases | +|---|---|---| +| 0x00 | REQ | Authorized/unauthorized request, destination mismatch, malformed body | +| 0x01 | RESPONSE | Matching, unsolicited, stale and malformed response | +| 0x02 | TXT_MSG | Direct/flood messaging, encryption, ACK/retry, duplicate delivery | +| 0x03 | ACK | Match/mismatch, short body, late/duplicate ACK, queue ownership | +| 0x04 | ADVERT | Valid/invalid signature, name/location boundaries, contact/path updates | +| 0x05 | GRP_TXT | Correct/wrong channel key, UTF-8 and message size boundaries | +| 0x06 | GRP_DATA | Binary data, maximum length, unknown channel, malformed body | +| 0x07 | ANON_REQ | Valid/invalid crypto, truncated identity/body, access control | +| 0x08 | PATH | Hash widths, empty/full paths, destination mismatch, path updates | +| 0x09 | TRACE | Hop processing, growing response, bounds and special dedup hash | +| 0x0A | MULTIPART | Implemented subtype behavior including ACK; unsupported subtype handling | +| 0x0B | CONTROL | Supported subtypes, flags, short bodies and unsupported commands | +| 0x0C–0x0E | Reserved | Explicit unsupported behavior; no unintended side effects | +| 0x0F | RAW_CUSTOM | Delivery/forward policy, empty/max payload, unsupported consumer | + +PACKET-001: enumerate all 256 header bytes and all 256 encoded path-length bytes in +parser tests. Cover all four routes and version-bit combinations, documenting the +current protocol's allowed/unsupported behavior rather than inventing restrictions. + +PACKET-002: lengths 0/1, minimal headers, truncated transport codes, absent payload, +path truncation at every byte, payload 1/183/184/185 and total unit boundary. +Current constants: payload 184, path buffer 64, transmission unit 255. Boundary +expectations are independently specified; flag constant changes for review. + +PACKET-003: hash widths 1/2/3, reserved width 4, zero hops, maximum valid count for +each width, one-over-limit and source buffers one byte too short. Verify zero-hop +`0x40`/`0x80` path encodings survive copies. Test helper and untrusted-call-site +contracts separately: a serializer's internal trusted-input contract is not a parser. + +PACKET-004: independently reviewed golden wire bytes and expected decoded fields, +including transport-code byte order. Round trips supplement golden vectors; they +cannot catch encoder and decoder sharing the same mistake. + +PACKET-005: golden packet hashes; ordinary path changes versus TRACE path-length +changes; payload/type changes; dedup collisions and expiry. Do not infer integrity +from the truncated dedup hash. + +PACKET-006: fuzz parser, advertisement, path, companion framing and CLI entry points +with sanitizers and bounded resources. Fixed seeds/corpora in quick CI; time-bounded +extended fuzzing nightly. Save minimized crashes as permanent deterministic tests. +Do not pass values outside a C++ API's representable input type and claim coverage. + +## Scheduling, routing and mesh behavior + +- MESH-001: real packet pool exhaustion, enqueue ordering, priority and due-time + ties, rescheduling, manual hold/release and every error-path ownership transfer. + Free + queued + in-flight + held counts must reconcile at quiescent checkpoints. +- MESH-002: fake clock/RNG through Dispatcher; timer expiry one tick before/at/after + deadlines, 32-bit wrap, airtime budget refill/debit/cap, failed-send accounting, + RX accounting, stats reset while active and maintenance-only wakeups. +- MESH-003: direct, flood and transport routes; matching/nonmatching next hop; + append/consume paths; loop prevention; duplicate suppression and cache eviction; + region filters, transport keys and admin exceptions. +- MESH-004: ContentionTracker warmup/EMA/decay, ring eviction, duplicate-window + boundaries, reactive backoff caps, multiplier limits and timer wrap. +- MESH-005: ACK loss, delayed/duplicate/out-of-order ACK, retry exhaustion, path + discovery/fallback, no duplicate user-visible delivery where required by protocol. +- MESH-006: A → relay → B, two relays, competing relays, relay loss/rejoin and + asymmetric links. Model topology deterministically in software first. +- MESH-007: role-specific forwarding and delivery; observer never transmits LoRa; + room login/post/fanout/history, offline clients and slow clients; repeater ACL, + rate limiting, neighbors and region configuration. + +Four nearby boards do not enforce a multi-hop RF topology. Prove forwarding with +path/capture evidence and, when testing lack of a direct link, use RF isolation or +attenuation. Simulated topology results must not be labeled physical range tests. + +## Remaining subsystem coverage + +| Family | Required cases | +|---|---| +| CRYPTO | Known-answer signatures, verification, key agreement, encryption/MAC; tamper/wrong key/truncation; accelerated vs software results; RNG error propagation and boot uniqueness smoke (not proof of entropy quality) | +| TRANSPORT | USB/UART/TCP split and coalesced frames, endian length, oversize/zero frames, garbage resync, frame timeout, disconnect midway, reconnect, saturation and response/push loss policy | +| BLE | Real GATT exchange, pairing/bonding, MTU fragmentation, notification backpressure, reconnect, bond removal, concurrent LoRa load; requires host BLE adapter | +| COMMAND | Inventory every companion opcode and CLI command; valid/min/max/truncated/unknown input, authorization, response bytes, persistence and side effects; fail catalog audit on newly uncategorized command | +| STORAGE | Identity/prefs/contacts/channels/ACL/keys; save/reboot/load, migration fixtures, corrupt/truncated files, full storage, failure during write and recovery without silent identity loss | +| TIME | RTC unset, forward/backward changes, time sync, expired/future timestamps, reboot, monotonic scheduling independent of wall-clock jumps | +| POWER | Sleep/wake with pending traffic, RX duty operation, GPS power gating, USB/BLE interactions; current measurement only with instrumented equipment | +| GPS/SENSORS | No device/no fix, partial/malformed data, timeout/recovery, valid position, battery conversion boundaries and sensor errors; fixture injection plus real-device smoke | +| UI | Buttons/joystick, menu actions, display sleep/wake, buzzer/haptic gates, long/invalid UTF-8; manual visual checks explicitly reported | +| NETWORK | Observer MQTT reconnect/backpressure, WiFi loss, uplink loops, credentials persistence, no inadvertent LoRa transmit | +| OTA/BOOT | Release image boot, board/partition compatibility, update/reboot/state preservation, interrupted/rejected update and recovery according to supported bootloader behavior | +| BUILD | All supported boards/roles; feature configurations, patched-driver presence, memory budgets, signed/merged/UF2/DFU artifacts as applicable; inspect actual release outputs | + +For each family record unsupported features as non-applicable, not passing tests. +Hardware power loss requires a controllable supply/USB relay; soft reboot does not +prove power-failure safety. Test destructive storage/OTA scenarios only on designated +test devices with known recovery paths. + +## Observability and fault injection + +First use existing production APIs, CLI, counters and captures. Add only the seams +needed to make otherwise invisible transitions testable. Prefer structured test +events over assertions that depend on human-readable debug text. + +Proposed test-only build option (name to be chosen during implementation): expose +capabilities, boot ID, TX accepted/completed/failed, RX accepted/dropped, queue/pool +counts, active preset and driver state. Fault injection can suppress a completion, +return a driver error or alter an event order. It must be off in release configs +and unreachable through production remote commands. CI checks that exclusion. + +Compile real production algorithms; fakes replace external inputs, not the logic +being tested. Use fake time to advance hours/wraparound instantly, and deterministic +RNG for repeatable jitter. For kernel behavior, run real Zephyr timers/queues in +native integration tests. Include production builds in HIL to catch instrumentation +changing timing, USB routing, logging volume or memory pressure. + +## Concrete smoke acceptance + +Version 1 smoke, after implementation: + +1. Preflight all four identities and firmware hashes; reserve devices; save settings; + apply a fixed test configuration and capture fresh boot/session markers. +2. Run all 12 directed links, initially 20 uniquely identified valid frames per link + at one fixed preset, with other nodes passive. Record bytes and RSSI/SNR per frame. +3. Require all expected frames byte-exact and exactly once in the isolated raw-link + case. A missing frame fails this strict smoke criterion; use control runs to + diagnose interference rather than silently weakening the result. This small + sample is a connectivity gate, not a high-reliability statistical claim. +4. Exercise representative direct/channel messages and advertisements; verify + application receipt and expected ACK behavior, not just sender API success. +5. Run at least 100 TX/RX reversals per board within the airtime budget. Verify RX + returns, queues drain, and no unexplained reset or leaked packet remains. +6. Reboot each board in turn and verify it rejoins and completes another exchange. +7. Restore settings, release ports/locks and emit report even on failure. + +Longer RF profiles specify sample count, offered load, loss limit, latency bound and +confidence method before execution. Report packet delivery with a binomial confidence +interval and latency percentiles; compare like-for-like baseline sessions and include +a control link. Do not set a universal RSSI value or a universal millisecond deadline: +radio airtime varies with modem configuration. Calibration data and reviewed limits +belong to the lab/profile version. Infrastructure failures leave qualification incomplete. + +## Implementation milestones and completion gates + +### 1. Establish the permanent software foundation + +- Add catalog/profile/report schemas and runner `list`/`quick`; no device dependency. +- Link Packet, real pool, ContentionTracker and Dispatcher into tests with fake + clock/radio/RNG. Resolve minimal logging/crypto dependencies without broad refactors. +- Implement PACKET-001–005 and MESH-001/002/004 high-risk boundaries. +- Establish the source coverage inventory above; include advertisement/UTF-8, + routing policy, rate limiting and identity/crypto vectors in the initial software + track. Continue with Mesh, BaseChatMesh, ACL, region/key and time-sync fixtures + without waiting for hardware-runner completion. +- Add golden fixtures and the zero-hop path and LBT streak regressions already + described in source comments. Demonstrate each targeted regression fails when + its known defect is deliberately reintroduced in a disposable worktree. +- Add PR CI, sanitizer runs where supported, JUnit/artifact output and coverage. +- Apply the per-module checklist and implement HARNESS-001/002/008 first. Add + bounded property tests for packet inputs and pool ownership, plus fixture isolation + checks. Expand harness self-tests as discovery/flashing services are introduced. + +Done: a documented command from a clean checkout runs actual production code, +reports failures with stable IDs, requires no boards and cannot pass an empty suite. + +### 2. Make the four-board smoke repeatable + +- Implement lab map/discovery/locking, transport adapters, firmware manifest checks, + role-specific build/flash recipes and guaranteed report/cleanup paths. +- Build minimal raw radio test application plus production-firmware packet scenarios. +- Implement RADIO-001–004/015 and the smoke procedure above. +- Prove recovery after an intentionally unplugged board and an interrupted run. +- Respect nRF54 `--no-sysbuild` and actual ESP32 role/release boot layouts; derive + flash recipes from resolved artifacts instead of one generic flash command. + +Done: reconnect the same boards next week and run the same cases without rewriting +scripts; failures have raw evidence and a one-case reproduction command. + +### 3. Expand deterministic failure and protocol coverage + +- Complete packet-type/role matrix, fake network routing, transport/opcode tests, + radio-base kernel integration and driver failure seams. +- Add parser fuzzing, saved-state fixtures and permanent cases for historical fixes. +- Extend HIL presets, payloads, CAD/LBT, overflow, duty cycle and role rotations. + +Done: every packet type and command is accounted for; unsupported, unimplemented +and unobservable cases remain visible, with owners and prerequisites. + +### 4. Qualify releases and long-run behavior + +- Add reference Arduino MeshCore fixtures/images pinned to a specific revision; + both directions for supported wire behavior, keeping ZephCore-only behavior separate. +- Run candidate/baseline mixes and production release binaries, persistence/OTA, + BLE/network scenarios and soak profiles. +- Add missing radio boards or instrumented fixtures as coverage needs justify them. + +Done: release report names its precise supported board/role/feature coverage, +has no missing mandatory cases and retains comparable baseline artifacts. + +## Ongoing change policy + +Every bug fix adds a minimized permanent reproduction at the lowest useful layer; +driver/timing bugs also get HIL coverage when physically reproducible. New packet +types, commands and features must update the catalog. Do not regenerate expected +golden bytes from the implementation under test during ordinary runs. + +Run quick on every change, integration/build checks on PRs, hardware smoke after +radio/driver/transport changes, full bench before release, and scheduled fuzz/soak +when a dedicated rig is available. Change-based selection supplements a fixed core +suite; a shared driver patch must not evade checks because only application paths +were mapped. Serialize bench jobs and isolate west trees used by builds that patch +dependencies at configure time. + +Track branch coverage for owned code, covered requirements, supported configurations, +fault scenarios and hardware execution separately. Initially measure coverage rather +than inventing an overall percentage target; then ratchet thresholds for modules +with meaningful tests. Review missing error branches and use targeted mutation checks +to establish that assertions detect the failures they claim to cover. + +## Framework references + +- [Zephyr Ztest](https://docs.zephyrproject.org/latest/develop/test/ztest.html) +- [Zephyr Twister](https://docs.zephyrproject.org/latest/develop/twister/index.html) +- [Zephyr pytest harness](https://docs.zephyrproject.org/latest/develop/twister/harness/pytest.html) + +These explain the recommended framework capabilities; the repository's pinned +Zephyr source remains authoritative for the implementation's available APIs. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..49df4c7 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,60 @@ +cmake_minimum_required(VERSION 3.20) +project(zephcore_host_tests LANGUAGES C CXX) + +option(ZEPHCORE_TEST_SANITIZERS "Enable host address/undefined behavior sanitizers" OFF) +set(PRODUCTION "${CMAKE_CURRENT_LIST_DIR}/../zephcore") +add_executable(zephcore_unit + unit/main.cpp + unit/packet_tests.cpp + unit/policy_tests.cpp + unit/pool_tests.cpp + unit/contention_tests.cpp + unit/advert_tests.cpp + unit/dispatcher_tests.cpp + unit/identity_tests.cpp + unit/timesync_tests.cpp + unit/helper_tests.cpp + unit/radio_tests.cpp + unit/lora_timing_tests.cpp + unit/prefs_codec_tests.cpp + unit/prefs_json_tests.cpp + unit/lpp_tests.cpp + unit/telemetry_text_tests.cpp + support/hash_capture.cpp + support/identity_utils_boundary.cpp + ${PRODUCTION}/src/Dispatcher.cpp + ${PRODUCTION}/src/Identity.cpp + ${PRODUCTION}/lib/monocypher/monocypher.c + ${PRODUCTION}/lib/monocypher/monocypher-ed25519.c + ${PRODUCTION}/src/Packet.cpp + ${PRODUCTION}/src/StaticPoolPacketManager.cpp + ${PRODUCTION}/src/ContentionTracker.cpp + ${PRODUCTION}/helpers/AdvertDataHelpers.cpp + ${PRODUCTION}/helpers/MeshTimeSync.cpp + ${PRODUCTION}/helpers/battery_curve.c + ${PRODUCTION}/adapters/radio/CadController.cpp + ${PRODUCTION}/helpers/PrefsCodec.cpp + ${PRODUCTION}/helpers/PrefsJson.cpp + ${PRODUCTION}/helpers/ConfigSerializer.cpp + ${PRODUCTION}/helpers/ui/telemetry_text.cpp +) +target_compile_features(zephcore_unit PRIVATE cxx_std_17) +target_compile_definitions(zephcore_unit PRIVATE CONFIG_ZEPHCORE_PACKET_LOGGING=0 + CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK=1 CONFIG_ZEPHCORE_UI_DESIGN_BUTTON=0) +target_include_directories(zephcore_unit PRIVATE + support unit ${PRODUCTION} ${PRODUCTION}/include ${PRODUCTION}/helpers ${PRODUCTION}/helpers/compat + ${PRODUCTION}/lib/monocypher + ${PRODUCTION}/adapters/radio + ${PRODUCTION}/patches/zephyr-new/include ${PRODUCTION}/patches/zephyr-new/drivers/lora/lr11xx) +if(MSVC) + target_compile_options(zephcore_unit PRIVATE /W4) +else() + target_compile_options(zephcore_unit PRIVATE -Wall -Wextra -Wpedantic) +endif() +if(ZEPHCORE_TEST_SANITIZERS) + if(WIN32 OR MSVC) + message(FATAL_ERROR "Use the Linux host target for address/undefined sanitizers") + endif() + target_compile_options(zephcore_unit PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer -fno-pie) + target_link_options(zephcore_unit PRIVATE -fsanitize=address,undefined -no-pie) +endif() diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..a653bb5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,158 @@ +# Hardware-free regression tests + +Run from the repository root: + +```sh +python tests/run.py run --profile quick +``` + +This first implementation builds **actual production C++ sources** and executes +the named native cases listed in `catalog.json` (114 on 2026-09-25) plus the Python +harness self-tests. No boards, Zephyr workspace, +west, network access or third-party Python packages are required. This is the first +subset of the [regression plan](../docs/REGRESSION_TEST_PLAN.md), not completion of +that plan or a release qualification suite. + +## Prerequisites + +- Python 3.10 or newer. +- CMake 3.20 or newer and Ninja on PATH. +- Host GCC or Clang C and C++ compilers, with C++17 support. C is used for the + bundled production Monocypher library. + +On Windows, the runner also looks for GCC in the standard MSYS2 `ucrt64` and +`mingw64` locations, and Clang in `clang64`. It adds the selected compiler's directory +to the child PATH so compiler tools and runtime DLLs can be found. Override discovery +with `CXX` set to an executable name or full path (not a shell command with flags). +The runner currently supports GCC/Clang discovery, not automatic Visual Studio setup. + +```powershell +$env:CXX = 'C:/msys64/mingw64/bin/g++.exe' +python tests/run.py run --profile quick +``` + +On Linux, run the instrumented profile: + +```sh +python3 tests/run.py run --profile quick --sanitize +``` + +This enables AddressSanitizer and UndefinedBehaviorSanitizer with failing diagnostics +by default. Sanitizer execution is not supported by this target on Windows. The Linux +test executable is non-PIE to avoid sanitizer address-space placement conflicts. + +## Commands + +```sh +# Enumerate the implemented native cases without compiling. +python tests/run.py list --profile quick + +# Reproduce one native case; harness self-tests still run first. +python tests/run.py run --case UNIT-PACKET-002 + +# Run only the Python runner's self-tests. +python -m unittest discover -s tests/harness_tests -v +``` + +`quick` is the only implemented profile. Unknown cases/profiles and empty collections +fail explicitly. The catalog is JSON to keep the first runner dependency-free; it +serves the catalog role proposed as YAML in the plan. Do not regenerate the catalog +on ordinary runs: the runner compares it to the compiled executable's full case list +and rejects missing, unexpected, renamed or duplicate cases. + +## What is covered + +| Production code | Cases | +|---|---| +| `src/Packet.cpp` | All path encodings and header bytes, payload boundaries, truncated headers/paths, independent wire vector, zero-hop width regression, 2,000 valid generated round trips, 8,192 arbitrary parser inputs, hash input rules | +| `src/StaticPoolPacketManager.cpp` | Capacity/reuse, due-time and priority ordering, reschedule/remove, time wrap, 5,000 generated ownership transitions | +| `src/ContentionTracker.cpp` | Observation boundaries/wrap, duplicate saturation, exactly-once finalization, reactive caps, warmup/eviction and time-based decay | +| `helpers/AdvertDataHelpers.cpp`, `UTF8Helpers.h` | Independent advertisement bytes, optional-field truncation, bounded names, UTF-8 invalid sequences and codepoint boundaries | +| `helpers/RoutingPolicy.h` | Reply route/scope truth tables and scoped/unscoped/advert hop limits | +| `helpers/RateLimiter.h` | Normal window limit, expiry and reset only | +| `include/mesh/Maintenance.h` | Due/past/future deadlines, wraparound and idle composition | +| `src/Dispatcher.cpp` | Immediate wakes, scheduled TX, completion/timeout ownership, priority-preserving refusal, LBT starvation across retry gaps, final RX gate, busy recovery, budget refill/admin exception, RX draining/hold/retransmit, maintenance, truncated headers, stats reset, clock wrap and 4,096 generated parser inputs | +| `src/Identity.cpp` and bundled Monocypher | Two RFC8032 Ed25519 known-answer vectors, tampered signatures/messages, wrong signer, canonical/legacy/private-only storage, failed-read state preservation and bidirectional ECDH | + +The catalog maps stable case IDs to their test source files. Production paths above +are relative to `zephcore/`. Generated-case seeds are fixed and documented in the +report; this version has no `--seed` override. Assertion output includes source and +line. Native cases run in separate processes because the production pool has global +state; tests never patch/reset that private state. Pool tests exercise repeated use +within each case and return owned slots through public APIs. + +External boundaries are deliberately limited: + +- Zephyr logging: a test-local header discards messages. It does not emulate a kernel. +- SHA-256: a test-local definition captures the hash input and returns marker bytes. + This verifies **Packet's choice of bytes to hash, not cryptographic correctness**. + `Utils.cpp` is not linked; the fake cannot support real crypto or mesh integration. +- Dispatcher uses a scripted `Radio`, fake millisecond clock and the **real** static + packet pool. Tests override CAD retry delay to 100ms; the default jitter's random + call has a deterministic host substitute. This exercises scheduler logic, not IRQs + or kernel concurrency. Packet logging is disabled explicitly in this target. +- Identity's hex constructors, RNG constructor, private-key validation and recovery + depend on Utils integration that is not implemented here. Those utility boundaries + throw if reached, rather than silently producing fake cryptographic results. + `fromSeed`, signing/verification, ECDH and storage serialization use the actual + production Identity and Monocypher code, including Monocypher's real SHA-512. + +The Ed25519 seeds, public keys and signatures are public test vectors from +[RFC 8032 section 7.1, tests 1 and 2](https://www.rfc-editor.org/rfc/rfc8032.txt). +They are fixed in the test source and never fetched during a run. ECDH currently has +a bidirectional agreement/nonzero property, not an independent known-answer vector. +Storage compatibility tests exercise all three layouts and verify restored signatures; +an independently archived historical storage corpus is still planned. + +Golden byte vectors are authored independently of the serializer. Generated round +trips supplement them. Native little-endian wire expectations match the current +supported MCU targets; this suite does not establish big-endian compatibility. + +Not yet covered: Mesh/application roles, PSA AES/HMAC/SHA-256, the remaining identity +entry points described above, filesystem, +Zephyr kernel behavior, driver/hardware behavior, coverage percentages, exhaustive +feature configurations, and RateLimiter overflow/wrap. The broader source inventory, +fuzz engine, fixture corpus and mutation automation remain planned. There are no +passing placeholders for these areas. + +## Results and failure behavior + +Each invocation creates unique directories under ignored `build-tests/` and +`test-results/`. Builds are isolated between concurrent invocations; the initial +small suite rebuilds on each run rather than sharing a mutable build cache. + +The result directory contains: + +- `results.json`: statuses, selected IDs, source hashes including untracked test + files, Git revision/status/diff hash, compiler version, host/Python metadata, + binary hash, fixed seeds, sanitizer options and executed build commands. +- `junit.xml`: native and harness results for CI. Missing prerequisites are BLOCKED + in JSON and errors in JUnit, never successful skips. +- `configure.log`, `build.log`, `harness.log` and one log per executed native case. + +Exit code 0 means every selected native case and harness test passed. Selection or +argument errors return 2. Build errors, crashes, timeouts, assertion failures or +incomplete execution return nonzero and retain available diagnostics. No automatic +retries. A child must both exit successfully and emit its exact case success marker. +The runner captures subprocess output directly; it never treats old logs as results. + +Per-native-case deadline is 10 seconds, discovery 30 seconds, and each build phase +180 seconds. Hard termination or disk failure can prevent report writing; absence of +a complete report is not a pass. Build/result directories can be removed when no run +is active; they are disposable. No personal lab configuration is needed for `quick`. + +## Adding a test + +1. Add a `TEST(function_name, "UNIT-AREA-NNN", "description")` to a relevant + `unit/*_tests.cpp` file; add a new source to CMake if needed. +2. Add its exact ID, description and source path to `catalog.json`. +3. Test a behavioral contract with independent expected values. Use `CHECK`, which + remains active in release builds; do not use C `assert` for test assertions. +4. Keep hardware calls behind explicit boundaries. Never copy a production algorithm + into a test substitute or weaken an expected result to match an observed defect. +5. Run the case and full quick profile; run Linux sanitizers for memory/parser changes. + +Harness self-tests cover empty selection, unknown IDs, catalog disagreement, +subprocess failures/timeouts, missing executables, false success markers and reports. +They are part of every quick run. The dedicated CI workflow runs on PRs and pushes +to master; it uploads reports even when checks fail and has no release permissions. diff --git a/tests/catalog.json b/tests/catalog.json new file mode 100644 index 0000000..3f456a5 --- /dev/null +++ b/tests/catalog.json @@ -0,0 +1,526 @@ +{ + "schema_version": 1, + "profile": "quick", + "cases": [ + { + "id": "UNIT-ADVERT-001", + "name": "Independent advertisement vector", + "source": "tests/unit/advert_tests.cpp" + }, + { + "id": "UNIT-ADVERT-002", + "name": "Optional fields reject truncation and names stay bounded", + "source": "tests/unit/advert_tests.cpp" + }, + { + "id": "UNIT-BATTERY-001", + "name": "Battery endpoints interpolation multicell scaling and monotonicity", + "source": "tests/unit/helper_tests.cpp" + }, + { + "id": "UNIT-CAD-001", + "name": "Level window narrows only where the driver clamp binds", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-CAD-002", + "name": "Configure keeps the learned peak across a base change and clamps to the window", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-CAD-003", + "name": "Staircase steps up below the knee, down on a clean plateau and holds at the knee", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-CAD-004", + "name": "Descent into the airtime cap is blocked by the hysteresis band", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-CAD-005", + "name": "Safety rung steps up on a pathological or over-cap level regardless of auto", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-CAD-006", + "name": "Visits use their own offset and TX starvation relaxes the offset in force", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-CAD-007", + "name": "Probe levels sweep when manual and mix operating and neighbours when auto", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-CAD-008", + "name": "Pending verdicts book tp or fp, unresolved ones are withdrawn and reset drops them", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-CONTENTION-001", + "name": "Observation window exact boundary and wrap", + "source": "tests/unit/contention_tests.cpp" + }, + { + "id": "UNIT-CONTENTION-002", + "name": "Duplicate saturation and exactly-once finalization", + "source": "tests/unit/contention_tests.cpp" + }, + { + "id": "UNIT-CONTENTION-003", + "name": "Reactive airtime and hard caps plus disabled backoff", + "source": "tests/unit/contention_tests.cpp" + }, + { + "id": "UNIT-CONTENTION-004", + "name": "Warmup eviction and decay independent of tick frequency", + "source": "tests/unit/contention_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-001", + "name": "Immediate wake delayed dispatch and exactly-once completion", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-002", + "name": "TX timeout releases ownership and next send recovers", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-003", + "name": "Driver refusal preserves priority schedule and accounting", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-004", + "name": "LBT starvation survives interleaved retry-gap wakes", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-005", + "name": "Final RX gate requeues without driver call or lost priority", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-006", + "name": "Persistent RX busy recovers and resumes sending", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-007", + "name": "Airtime budget gates and refills at the exact threshold", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-008", + "name": "Due admin packet can transmit after budget depletion", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-009", + "name": "One wake drains RX ring rejects invalid packets and returns slots", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-010", + "name": "Manual hold and delayed retransmit retain correct ownership", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-011", + "name": "Maintenance never consumes TX completion and reports CAD changes", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-012", + "name": "Stall watchdog latches without perpetual immediate deadline", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-013", + "name": "Invalid outbound packet releases slot without queue wake", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-014", + "name": "Dispatcher parser rejects incomplete route headers before reading", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-015", + "name": "TX accounting uses modem airtime across stats reset while active", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-016", + "name": "Delayed send and TX expiry operate across clock wrap", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-DISPATCH-017", + "name": "Generated dispatcher inputs obey version header and path bounds", + "source": "tests/unit/dispatcher_tests.cpp" + }, + { + "id": "UNIT-IDENTITY-001", + "name": "RFC8032 Ed25519 public keys and exact signatures", + "source": "tests/unit/identity_tests.cpp" + }, + { + "id": "UNIT-IDENTITY-002", + "name": "Reject altered signature message and wrong signer", + "source": "tests/unit/identity_tests.cpp" + }, + { + "id": "UNIT-IDENTITY-003", + "name": "Canonical legacy and private-only storage retain signing identity", + "source": "tests/unit/identity_tests.cpp" + }, + { + "id": "UNIT-IDENTITY-004", + "name": "Malformed storage fails without replacing existing identity", + "source": "tests/unit/identity_tests.cpp" + }, + { + "id": "UNIT-IDENTITY-005", + "name": "Real Ed25519-to-X25519 exchange agrees in both directions", + "source": "tests/unit/identity_tests.cpp" + }, + { + "id": "UNIT-LORATIME-001", + "name": "Preamble grace is (preamble+8) symbols rounded up with an unconfigured default", + "source": "tests/unit/lora_timing_tests.cpp" + }, + { + "id": "UNIT-LORATIME-002", + "name": "Max-payload bound is 255 B at CR 4/8 with LDRO plus 25% and 100 ms", + "source": "tests/unit/lora_timing_tests.cpp" + }, + { + "id": "UNIT-LORATIME-003", + "name": "Chip timer conversion saturates at 24 bits instead of wrapping", + "source": "tests/unit/lora_timing_tests.cpp" + }, + { + "id": "UNIT-LORATIME-004", + "name": "LR11xx detPeak base table cells symbol adjustment and clamp headroom", + "source": "tests/unit/lora_timing_tests.cpp" + }, + { + "id": "UNIT-NOISE-001", + "name": "Noise floor seeds from the first sample within the clamp window", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-NOISE-002", + "name": "Warm-up accepts everything then outliers are rejected except the periodic bypass", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-NOISE-003", + "name": "EMA step rounds to nearest symmetrically with a +-3 dB dead zone", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-NOISE-004", + "name": "Median of an even burst and the quiet verdict against the floor", + "source": "tests/unit/radio_tests.cpp" + }, + { + "id": "UNIT-PACKET-001", + "name": "Exhaust all path encodings", + "source": "tests/unit/packet_tests.cpp" + }, + { + "id": "UNIT-PACKET-002", + "name": "Zero-hop hash-width regression and bounded copying", + "source": "tests/unit/packet_tests.cpp" + }, + { + "id": "UNIT-PACKET-003", + "name": "Independent transport-direct wire vector", + "source": "tests/unit/packet_tests.cpp" + }, + { + "id": "UNIT-PACKET-004", + "name": "All header bytes and payload boundaries", + "source": "tests/unit/packet_tests.cpp" + }, + { + "id": "UNIT-PACKET-005", + "name": "Reject truncated headers and paths", + "source": "tests/unit/packet_tests.cpp" + }, + { + "id": "UNIT-PACKET-006", + "name": "Seeded valid packets preserve wire fields", + "source": "tests/unit/packet_tests.cpp" + }, + { + "id": "UNIT-PACKET-007", + "name": "Seeded arbitrary input parser invariants", + "source": "tests/unit/packet_tests.cpp" + }, + { + "id": "UNIT-PACKET-008", + "name": "Ordinary and TRACE hash input contracts (not crypto)", + "source": "tests/unit/packet_tests.cpp" + }, + { + "id": "UNIT-PJSON-001", + "name": "Companion prefs survive prefs.json field for field", + "source": "tests/unit/prefs_json_tests.cpp" + }, + { + "id": "UNIT-PJSON-002", + "name": "Server prefs survive prefs.json field for field", + "source": "tests/unit/prefs_json_tests.cpp" + }, + { + "id": "UNIT-PJSON-003", + "name": "A companion prefs.json as upstream v1.17 writes it loads", + "source": "tests/unit/prefs_json_tests.cpp" + }, + { + "id": "UNIT-PJSON-004", + "name": "A broken prefs.json leaves prefs untouched, an empty object changes nothing", + "source": "tests/unit/prefs_json_tests.cpp" + }, + { + "id": "UNIT-PJSON-005", + "name": "Quotes, backslashes, newlines and UTF-8 in strings round trip", + "source": "tests/unit/prefs_json_tests.cpp" + }, + { + "id": "UNIT-PJSON-006", + "name": "Frequencies and bandwidths survive the 4-decimal float encoding exactly", + "source": "tests/unit/prefs_json_tests.cpp" + }, + { + "id": "UNIT-PJSON-007", + "name": "An implausible radio preset in prefs.json falls back to the defaults", + "source": "tests/unit/prefs_json_tests.cpp" + }, + { + "id": "UNIT-PJSON-008", + "name": "Server gps.en: a file without zc.gps_set is upgraded to on once, then honoured", + "source": "tests/unit/prefs_json_tests.cpp" + }, + { + "id": "UNIT-LPP-001", + "name": "Values round to the nearest step (3850 mV is 3.85 V, not the library's 3.84)", + "source": "tests/unit/lpp_tests.cpp" + }, + { + "id": "UNIT-LPP-002", + "name": "Negative values are two's complement in the field, rounded on the magnitude", + "source": "tests/unit/lpp_tests.cpp" + }, + { + "id": "UNIT-LPP-003", + "name": "GPS: 0.0001 deg and 0.01 m, 3 bytes each, rounded", + "source": "tests/unit/lpp_tests.cpp" + }, + { + "id": "UNIT-LPP-004", + "name": "A field that does not fit is refused whole; the buffer keeps what fit", + "source": "tests/unit/lpp_tests.cpp" + }, + { + "id": "UNIT-TTEXT-001", + "name": "A repeater's telemetry in slice-10 order renders every field, GPS second", + "source": "tests/unit/telemetry_text_tests.cpp" + }, + { + "id": "UNIT-TTEXT-002", + "name": "Power monitor: channel voltage is not 'batt', current is signed", + "source": "tests/unit/telemetry_text_tests.cpp" + }, + { + "id": "UNIT-TTEXT-003", + "name": "A type the screen does not show is skipped, not the end; empty is 'no telemetry'", + "source": "tests/unit/telemetry_text_tests.cpp" + }, + { + "id": "UNIT-POLICY-001", + "name": "Reply routing and scope truth tables", + "source": "tests/unit/policy_tests.cpp" + }, + { + "id": "UNIT-POLICY-002", + "name": "Scoped unscoped and advert hop boundaries", + "source": "tests/unit/policy_tests.cpp" + }, + { + "id": "UNIT-POOL-001", + "name": "Exhaust reclaim and reuse all pool slots", + "source": "tests/unit/pool_tests.cpp" + }, + { + "id": "UNIT-POOL-002", + "name": "Only due packets dispatch in priority and tie order", + "source": "tests/unit/pool_tests.cpp" + }, + { + "id": "UNIT-POOL-003", + "name": "Reschedule remove and dispatch across clock wrap", + "source": "tests/unit/pool_tests.cpp" + }, + { + "id": "UNIT-POOL-004", + "name": "Seeded pool ownership conservation", + "source": "tests/unit/pool_tests.cpp" + }, + { + "id": "UNIT-PREFS-001", + "name": "Companion prefs encode to the documented 272-byte layout", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-PREFS-002", + "name": "Companion prefs decode what they encode", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-PREFS-003", + "name": "Short companion files keep the caller's defaults past the end", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-PREFS-004", + "name": "Companion decode rejects short or foreign data and leaves prefs untouched", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-PREFS-005", + "name": "Corrupt companion bytes fall back to the sanitizeNodePrefs defaults", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-PREFS-006", + "name": "Server prefs encode to the documented 311-byte layout", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-PREFS-007", + "name": "Server prefs decode what they encode", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-PREFS-008", + "name": "Short server files keep the caller's values past the end", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-PREFS-009", + "name": "Server LED magic byte and implausible radio params", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-PREFS-010", + "name": "GPS interval: one clamp, and a corrupt value is not turned into always-on", + "source": "tests/unit/prefs_codec_tests.cpp" + }, + { + "id": "UNIT-RATE-001", + "name": "Rate window threshold expiry and reset", + "source": "tests/unit/policy_tests.cpp" + }, + { + "id": "UNIT-RTC-001", + "name": "Unique timestamps survive repeated and backward wall time", + "source": "tests/unit/helper_tests.cpp" + }, + { + "id": "UNIT-STRING-001", + "name": "String copies terminate and respect zero exact and short capacities", + "source": "tests/unit/helper_tests.cpp" + }, + { + "id": "UNIT-STRING-002", + "name": "Decimal trimming and blank classification", + "source": "tests/unit/helper_tests.cpp" + }, + { + "id": "UNIT-SYNC-001", + "name": "Evaluation pacing and empty evidence leave clock untouched", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-002", + "name": "Normal quorum needs six tenured senders and two adverts", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-003", + "name": "Step threshold and positive negative one-hour caps", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-004", + "name": "Split vote abstains and strict majority defeats outliers", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-005", + "name": "Forward-only roles report but never apply backward corrections", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-006", + "name": "Bootstrap needs three fresh peers and bypasses normal step cap", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-007", + "name": "Manual and GPS suppression expire exactly at seven days", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-008", + "name": "Trusted-clock drift envelope rejects implausible consensus", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-009", + "name": "Clock steps remain rate-limited for six hours", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-010", + "name": "Hop limit monotonic dedup and eight-byte sender identity", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-011", + "name": "Stale samples expire and inconsistent sender must re-earn tenure", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-012", + "name": "Full evidence table protects mature and equally-close senders", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-013", + "name": "Status formatting respects every buffer capacity including zero", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-SYNC-014", + "name": "Reset discards evidence suppression counters and scheduling state", + "source": "tests/unit/timesync_tests.cpp" + }, + { + "id": "UNIT-TIME-001", + "name": "Maintenance deadlines across uint32 wrap", + "source": "tests/unit/policy_tests.cpp" + }, + { + "id": "UNIT-UTF8-001", + "name": "Complete codepoints only and invalid UTF-8 rejection", + "source": "tests/unit/advert_tests.cpp" + } + ] +} diff --git a/tests/harness_tests/test_runner.py b/tests/harness_tests/test_runner.py new file mode 100644 index 0000000..1ce1571 --- /dev/null +++ b/tests/harness_tests/test_runner.py @@ -0,0 +1,78 @@ +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +import xml.etree.ElementTree as ET + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import run + + +class RunnerTests(unittest.TestCase): + CASE = {"id": "ONE", "name": "Example", "source": "unit/example.cpp"} + + def test_empty_collection(self): + with self.assertRaises(ValueError): + run.select_cases([], None) + + def test_unknown_case(self): + with self.assertRaises(ValueError): + run.select_cases([self.CASE], "MISSING") + + def test_catalog_duplicate_ids(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "catalog.json" + path.write_text(json.dumps({"schema_version": 1, "cases": [self.CASE, self.CASE]})) + with self.assertRaises(ValueError): + run.load_catalog(path) + + def test_executable_catalog_agreement(self): + run.validate_listing("ONE\tExample\n", [self.CASE]) + for output in ("", "ONE\tExample\nONE\tExample\n", "TWO\tExample\n", "ONE\tWrong\n", "garbage"): + with self.assertRaises(ValueError): + run.validate_listing(output, [self.CASE]) + + def test_child_failure_retains_output(self): + result = run.command([sys.executable, "-c", "import sys; print('evidence'); sys.exit(7)"]) + self.assertEqual(result["status"], "FAIL") + self.assertEqual(result["returncode"], 7) + self.assertIn("evidence", result["stdout"]) + + def test_child_timeout(self): + result = run.command([sys.executable, "-c", "import time; print('started', flush=True); time.sleep(30)"], timeout=1) + self.assertEqual(result["status"], "ERROR") + self.assertIn("Timeout", result["stderr"]) + + def test_missing_executable(self): + with tempfile.TemporaryDirectory() as directory: + result = run.command([str(Path(directory) / "missing")]) + self.assertEqual(result["status"], "ERROR") + + def test_success_requires_matching_marker(self): + for output in ("", "PASS OLD", "PASS ONE\nextra"): + with patch.object(run, "command", return_value={"status": "PASS", "stdout": output, "stderr": ""}): + self.assertEqual(run.execute_case("unused", self.CASE)["status"], "ERROR") + + def test_failure_not_overridden_by_success_text(self): + with patch.object(run, "command", return_value={"status": "FAIL", "stdout": "PASS ONE", "stderr": "crash"}): + self.assertEqual(run.execute_case("unused", self.CASE)["status"], "FAIL") + + def test_report_counts_and_xml_escaping(self): + results = [{"id": str(i), "name": "<&>", "status": status, "stderr": "a < b & c"} + for i, status in enumerate(("PASS", "FAIL", "ERROR", "BLOCKED"))] + report = {"results": results} + with tempfile.TemporaryDirectory() as directory: + run.write_reports(Path(directory), report) + xml = ET.parse(Path(directory) / "junit.xml").getroot() + self.assertEqual(xml.attrib["tests"], "4") + self.assertEqual(xml.attrib["failures"], "1") + self.assertEqual(xml.attrib["errors"], "2") + self.assertEqual(len(xml.findall("testcase")), 4) + self.assertEqual(json.loads((Path(directory) / "results.json").read_text())["counts"], + {"PASS": 1, "FAIL": 1, "ERROR": 1, "BLOCKED": 1}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/run.py b/tests/run.py new file mode 100644 index 0000000..2a63e1b --- /dev/null +++ b/tests/run.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Dependency-free host regression runner. No board discovery or flashing.""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import time +import unittest +import uuid +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +TESTS = ROOT / "tests" + + +def command(args, *, timeout=30, env=None): + """Bounded child execution, preserving failure and timeout output.""" + started = time.monotonic() + try: + proc = subprocess.run([str(arg) for arg in args], cwd=ROOT, env=env, + capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=timeout) + return {"status": "PASS" if proc.returncode == 0 else "FAIL", + "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr, + "seconds": time.monotonic() - started} + except subprocess.TimeoutExpired as exc: + def decode(value): + return value.decode("utf-8", "replace") if isinstance(value, bytes) else (value or "") + return {"status": "ERROR", "returncode": None, "stdout": decode(exc.stdout), + "stderr": decode(exc.stderr) + "\nTimeout expired", "seconds": time.monotonic() - started} + except OSError as exc: + return {"status": "ERROR", "returncode": None, "stdout": "", "stderr": str(exc), + "seconds": time.monotonic() - started} + + +def load_catalog(path=TESTS / "catalog.json"): + data = json.loads(path.read_text(encoding="utf-8")) + cases = data["cases"] + if data.get("schema_version") != 1 or not cases: + raise ValueError("Unsupported or empty catalog") + ids = set() + for case in cases: + if not all(isinstance(case.get(key), str) and case[key] for key in ("id", "name", "source")): + raise ValueError("Invalid case fields") + if case["id"] in ids: + raise ValueError(f"Duplicate case ID: {case['id']}") + ids.add(case["id"]) + return cases + + +def select_cases(cases, requested): + selected = cases if requested is None else [case for case in cases if case["id"] == requested] + if not selected: + raise ValueError(f"No implemented cases match {requested!r}") + return selected + + +def validate_listing(output, cases): + actual = {} + for line in output.splitlines(): + parts = line.split("\t") + if len(parts) != 2 or not all(parts) or parts[0] in actual: + raise ValueError("Malformed or duplicate executable case listing") + actual[parts[0]] = parts[1] + expected = {case["id"]: case["name"] for case in cases} + if not actual or actual != expected: + raise ValueError(f"Executable/catalog mismatch: missing={sorted(expected.keys() - actual.keys())}, " + f"unexpected={sorted(actual.keys() - expected.keys())}; also check case names") + + +def execute_case(binary, case, env=None, timeout=10): + result = command([binary, "--case", case["id"]], timeout=timeout, env=env) + # Exit 0 alone is insufficient: reject stale/malformed/incomplete output. + if result["status"] == "PASS" and result["stdout"].strip() != f"PASS {case['id']}": + result["status"] = "ERROR" + result["stderr"] += "\nMissing or invalid per-case success marker" + return {"id": case["id"], "name": case["name"], **result} + + +def write_reports(directory, report): + results = report["results"] + report["counts"] = {status: sum(r["status"] == status for r in results) + for status in ("PASS", "FAIL", "ERROR", "BLOCKED")} + (directory / "results.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + suite = ET.Element("testsuite", name="zephcore.quick", tests=str(len(results)), + failures=str(report["counts"]["FAIL"]), + errors=str(report["counts"]["ERROR"] + report["counts"]["BLOCKED"])) + for result in results: + case = ET.SubElement(suite, "testcase", classname=result["id"], name=result["name"], + time=str(result.get("seconds", 0))) + if result["status"] != "PASS": + ET.SubElement(case, "failure" if result["status"] == "FAIL" else "error", + message=result["status"]).text = result.get("stderr", "") + ET.SubElement(case, "system-out").text = result.get("stdout", "") + ET.SubElement(case, "system-err").text = result.get("stderr", "") + ET.ElementTree(suite).write(directory / "junit.xml", encoding="utf-8", xml_declaration=True) + + +class HarnessResult(unittest.TextTestResult): + def startTest(self, test): + self.started = time.monotonic() + super().startTest(test) + + def record(self, test, status, detail=""): + self.records.append({"id": test.id(), "name": test.shortDescription() or test.id(), + "status": status, "stderr": detail, + "seconds": time.monotonic() - self.started}) + + def addSuccess(self, test): + super().addSuccess(test) + self.record(test, "PASS") + + def addFailure(self, test, err): + super().addFailure(test, err) + self.record(test, "FAIL", self._exc_info_to_string(err, test)) + + def addError(self, test, err): + super().addError(test, err) + self.record(test, "ERROR", self._exc_info_to_string(err, test)) + + def addSkip(self, test, reason): + super().addSkip(test, reason) + self.record(test, "BLOCKED", reason) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.records = [] + + +def harness_tests(directory): + suite = unittest.defaultTestLoader.discover(str(TESTS / "harness_tests")) + if suite.countTestCases() == 0: + raise ValueError("Harness test collection is empty") + with (directory / "harness.log").open("w", encoding="utf-8") as stream: + result = unittest.TextTestRunner(stream=stream, verbosity=2, resultclass=HarnessResult).run(suite) + return result.records + + +def tool_environment(): + env = os.environ.copy() + compiler = env.get("CXX") + if not compiler: + compiler = shutil.which("g++") or shutil.which("clang++") + if not compiler and os.name == "nt": + for path in ("C:/msys64/ucrt64/bin/g++.exe", "C:/msys64/mingw64/bin/g++.exe", + "C:/msys64/clang64/bin/clang++.exe"): + if Path(path).is_file(): + compiler = path + break + if not compiler: + raise ValueError("No host C++ compiler found. Set CXX to g++/clang++ (full path allowed).") + resolved = shutil.which(compiler) or str(Path(compiler).resolve()) + env["PATH"] = str(Path(resolved).parent) + os.pathsep + env.get("PATH", "") + env.setdefault("ASAN_OPTIONS", "detect_leaks=1:halt_on_error=1") + env.setdefault("UBSAN_OPTIONS", "halt_on_error=1:print_stacktrace=1") + return resolved, env + + +def metadata(): + def git(*args): + result = command(["git", *args]) + return result["stdout"].strip() if result["status"] == "PASS" else None + # Include untracked new test sources too. Runtime/build outputs live elsewhere. + paths = list(TESTS.rglob("*")) + list((ROOT / "zephcore/include").rglob("*.h")) + paths += list((ROOT / "zephcore/helpers").glob("*.h")) + paths += list((ROOT / "zephcore/lib/monocypher").glob("*")) + paths += [ROOT / "zephcore" / p for p in ( + "src/Packet.cpp", "src/StaticPoolPacketManager.cpp", "src/ContentionTracker.cpp", + "src/Dispatcher.cpp", "src/Identity.cpp", "helpers/AdvertDataHelpers.cpp")] + hashes = {p.relative_to(ROOT).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() + for p in paths if p.is_file() and (p.suffix in (".py", ".cpp", ".c", ".h", ".json") or p.name == "CMakeLists.txt")} + return {"git_commit": git("rev-parse", "HEAD"), "git_status": git("status", "--porcelain"), + "tracked_diff_sha256": hashlib.sha256((git("diff", "HEAD", "--binary") or "").encode()).hexdigest(), + "python": sys.version, "host": platform.platform(), "input_sha256": hashes, + "fixed_generator_seeds": ["0x5EED1234", "0x0BADCAFE", "0x12345678", "0xD15CA7C4"], + "limitations": ["No hardware or Zephyr kernel execution", "SHA-256 replaced by input capture", + "Identity hex/RNG/validation/recovery Utils boundaries are fail-fast stubs", + "Only implemented catalog cases; full plan remains incomplete"]} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("list", "run")) + parser.add_argument("--profile", choices=("quick",), default="quick") + parser.add_argument("--case", help="Exact native case ID") + parser.add_argument("--sanitize", action="store_true", help="Linux ASan + UBSan") + args = parser.parse_args(argv) + try: + cases = load_catalog() + selected = select_cases(cases, args.case) + except (ValueError, KeyError, OSError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + if args.action == "list": + for case in selected: + print(f"{case['id']} {case['name']}") + print(f"{len(selected)} native cases; run also executes harness self-tests.") + return 0 + + run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8] + directory = ROOT / "test-results" / run_id + directory.mkdir(parents=True) + report = {"schema_version": 1, "run_id": run_id, "profile": args.profile, + "selected_native_ids": [c["id"] for c in selected], "results": [], + "sanitizers_requested": args.sanitize, "commands": []} + binary = None + try: + report["metadata"] = metadata() + report["results"].extend(harness_tests(directory)) + if any(r["status"] != "PASS" for r in report["results"]): + raise ValueError("Harness self-tests failed; firmware tests blocked") + compiler, env = tool_environment() + version = command([compiler, "--version"], env=env) + if version["status"] != "PASS": + raise ValueError("Cannot query compiler: " + version["stderr"]) + report["compiler"] = version["stdout"] + report["sanitizer_environment"] = {key: env[key] for key in ("ASAN_OPTIONS", "UBSAN_OPTIONS")} + key = hashlib.sha256((compiler + version["stdout"] + str(args.sanitize)).encode()).hexdigest()[:12] + # Unique per run: simultaneous invocations cannot race on build artifacts. + build = ROOT / "build-tests" / f"{key}-{run_id}" + configure = ["cmake", "-S", TESTS, "-B", build, "-G", "Ninja", + f"-DCMAKE_CXX_COMPILER={compiler}", "-DCMAKE_BUILD_TYPE=Debug", + f"-DZEPHCORE_TEST_SANITIZERS={'ON' if args.sanitize else 'OFF'}"] + for phase, cmd in (("configure", configure), ("build", ["cmake", "--build", build, "--parallel", "2"])): + print(f"{phase.capitalize()} host tests...", flush=True) + report["commands"].append([str(part) for part in cmd]) + result = command(cmd, timeout=180, env=env) + (directory / f"{phase}.log").write_text(result["stdout"] + result["stderr"], encoding="utf-8") + if result["status"] != "PASS": + raise ValueError(f"{phase} failed; see {directory / (phase + '.log')}") + binary = build / ("zephcore_unit.exe" if os.name == "nt" else "zephcore_unit") + report["binary_sha256"] = hashlib.sha256(binary.read_bytes()).hexdigest() + listing = command([binary, "--list"], env=env) + if listing["status"] != "PASS": + raise ValueError("Executable discovery failed: " + listing["stderr"]) + validate_listing(listing["stdout"], cases) + for case in selected: + result = execute_case(binary, case, env) + report["results"].append(result) + (directory / (case["id"] + ".log")).write_text(result["stdout"] + result["stderr"], encoding="utf-8") + print(f"{result['status']:5} {case['id']} {case['name']}", flush=True) + except (ValueError, OSError, KeyboardInterrupt) as exc: + report["results"].append({"id": "RUNNER", "name": "Run infrastructure", "status": "ERROR", "stderr": str(exc) or "Interrupted"}) + finally: + completed = {r["id"] for r in report["results"]} + for case in selected: + if case["id"] not in completed: + report["results"].append({"id": case["id"], "name": case["name"], "status": "BLOCKED", + "stderr": "Prerequisite failed or run interrupted"}) + write_reports(directory, report) + print(f"Reports: {directory}\nCounts: {report['counts']}") + return 0 if all(r["status"] == "PASS" for r in report["results"]) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/support/dispatcher_fixture.h b/tests/support/dispatcher_fixture.h new file mode 100644 index 0000000..5a64b3f --- /dev/null +++ b/tests/support/dispatcher_fixture.h @@ -0,0 +1,101 @@ +#pragma once +#include "test.h" +#include +#include +#include +#include + +namespace test_support { +struct Clock : mesh::MillisecondClock { + uint32_t now = 100; + unsigned long getMillis() override { return now; } +}; +struct Radio : mesh::Radio { + bool receiving = false, ready = true, recv_mode = true, active = false; + bool accept = true, completion = false, block_final_gate = false; + unsigned receiving_queries = 0, attempts = 0, finished = 0, recoveries = 0; + unsigned completion_queries = 0, relaxations = 0, cad_ticks = 0, radio_ticks = 0; + uint32_t airtime = 100, maintenance = mesh::MAINTENANCE_IDLE; + int8_t cad_offset = 0; + std::deque> incoming; + std::vector> sent; + int recvRaw(uint8_t *bytes, int size) override { + if (incoming.empty()) return 0; + auto raw = incoming.front(); incoming.pop_front(); + CHECK(raw.size() <= unsigned(size)); + std::copy(raw.begin(), raw.end(), bytes); return int(raw.size()); + } + uint32_t getEstAirtimeFor(int) override { return airtime; } + float packetScore(float, int) override { return 0.5f; } + bool startSendRaw(const uint8_t *bytes, int len) override { + ++attempts; + if (!accept) return false; + sent.emplace_back(bytes, bytes + len); active = true; recv_mode = false; + return true; + } + bool isSendComplete() override { + ++completion_queries; + if (!completion) return false; + completion = false; active = false; return true; + } + void onSendFinished() override { ++finished; active = false; recv_mode = true; } + bool isTxActive() const override { return active; } + bool isInRecvMode() const override { return recv_mode; } + bool isReceiving() override { + ++receiving_queries; + return receiving || (block_final_gate && receiving_queries == 2); + } + bool isRadioReady() override { return ready; } + void recoverRxState() override { ++recoveries; receiving = false; ready = true; recv_mode = true; } + float getLastSNR() const override { return -2.25f; } + float getLastRSSI() const override { return -90.0f; } + bool cadRelaxOnTxStarvation() override { ++relaxations; return true; } + void cadMaintenance() override { ++cad_ticks; } + void radioMaintenance() override { ++radio_ticks; } + int8_t getCadOffset() const override { return cad_offset; } + uint32_t msUntilNextMaintenance() override { return maintenance; } +}; +struct Dispatcher : mesh::Dispatcher { + uint8_t duty = 0; + uint32_t window = 1000; + mesh::DispatcherAction action = ACTION_RELEASE; + std::vector received; + std::vector held; + std::vector wakes; + std::vector offsets; + unsigned successes = 0, failures = 0; + Dispatcher(Radio& radio, Clock& clock, mesh::PacketManager& pool) : mesh::Dispatcher(radio, clock, pool) { + setTxQueuedCallback([](uint32_t delay, void *self) { + static_cast(self)->wakes.push_back(delay); + }, this); + } + mesh::DispatcherAction onRecvPacket(mesh::Packet *p) override { + received.push_back(*p); + if (action == ACTION_MANUAL_HOLD) held.push_back(p); + return action; + } + uint8_t getDutyCyclePercent() const override { return duty; } + uint32_t getDutyCycleWindowMs() const override { return window; } + uint32_t getCADFailRetryDelay() const override { return 100; } + void logTx(mesh::Packet*, int) override { ++successes; } + void logTxFail(mesh::Packet*, int) override { ++failures; } + void onCadOffsetChanged(int8_t offset) override { offsets.push_back(offset); } +}; +struct Fixture { + Clock clock; + Radio radio; + StaticPoolPacketManager pool; + Dispatcher dispatcher{radio, clock, pool}; + Fixture() { + auto p = pool.allocNew(); CHECK(p != nullptr); pool.free(p); + } + void begin(uint8_t duty = 0) { dispatcher.duty = duty; dispatcher.begin(); } + mesh::Packet* packet(uint8_t type = PAYLOAD_TYPE_TXT_MSG, uint8_t route = ROUTE_TYPE_FLOOD) { + auto p = dispatcher.obtainNewPacket(); CHECK(p != nullptr); + p->header = (type << 2) | route; + p->transport_codes[0] = p->transport_codes[1] = 0; + p->payload_len = 1; p->payload[0] = 0xAA; return p; + } + void complete() { radio.completion = true; dispatcher.loop(); } +}; +} diff --git a/tests/support/hash_capture.cpp b/tests/support/hash_capture.cpp new file mode 100644 index 0000000..c7fd559 --- /dev/null +++ b/tests/support/hash_capture.cpp @@ -0,0 +1,18 @@ +#include +#include "hash_capture.h" +#include + +namespace test_support { +std::vector hash_input; +size_t hash_output_size = 0; +} + +// Tests inspect Packet's hash preimage, NOT SHA-256 correctness. Deterministic +// marker output must never be mistaken for a digest or used in firmware builds. +void mesh::Utils::sha256(uint8_t *hash, size_t size, const uint8_t *msg, int len) +{ + if (len < 0 || size != MAX_HASH_SIZE) throw std::runtime_error("unexpected hash call"); + test_support::hash_input.assign(msg, msg + len); + test_support::hash_output_size = size; + memset(hash, 0xA5, size); +} diff --git a/tests/support/hash_capture.h b/tests/support/hash_capture.h new file mode 100644 index 0000000..b2a7085 --- /dev/null +++ b/tests/support/hash_capture.h @@ -0,0 +1,8 @@ +#pragma once +#include +#include +#include +namespace test_support { +extern std::vector hash_input; +extern size_t hash_output_size; +} diff --git a/tests/support/identity_utils_boundary.cpp b/tests/support/identity_utils_boundary.cpp new file mode 100644 index 0000000..a5b3479 --- /dev/null +++ b/tests/support/identity_utils_boundary.cpp @@ -0,0 +1,41 @@ +#include +#include +#include + +// Identity tests exercise fromSeed, sign/verify, ECDH and storage serialization. +// Other constructors/private-key recovery need Utils.cpp/PSA integration later. +// Fail loudly if a new test accidentally reaches those unimplemented boundaries. +// Hex helpers are pure; copied from src/Utils.cpp for the prefs.json tests +// (ConfigSerializer stores binary blobs as hex). +static uint8_t hexVal(char c) { + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= '0' && c <= '9') return c - '0'; + return 0; +} +bool mesh::Utils::fromHex(uint8_t *dest, int dest_size, const char *src_hex) { + size_t len = strlen(src_hex); + if (len != (size_t)(dest_size * 2)) return false; + uint8_t *dp = dest; + while ((size_t)(dp - dest) < (size_t)dest_size) { + char ch = *src_hex++; + char cl = *src_hex++; + *dp++ = (uint8_t)((hexVal(ch) << 4) | hexVal(cl)); + } + return true; +} +void mesh::Utils::printHex(Stream &s, const uint8_t *src, size_t len) { + static const char hex_chars[] = "0123456789ABCDEF"; + while (len > 0) { + uint8_t b = *src++; + s.print(hex_chars[b >> 4]); + s.print(hex_chars[b & 0x0F]); + len--; + } +} +void mesh::Utils::secureZeroize(void*, size_t) { + throw std::runtime_error("Identity RNG/recovery path requires real Utils integration"); +} +bool mesh::Utils::constantTimeEqual(const void*, const void*, size_t) { + throw std::runtime_error("Identity validation requires real Utils integration"); +} diff --git a/tests/support/zephyr/kernel.h b/tests/support/zephyr/kernel.h new file mode 100644 index 0000000..e52bb92 --- /dev/null +++ b/tests/support/zephyr/kernel.h @@ -0,0 +1,4 @@ +#pragma once +#include +// The time-sync target uses only uptime, not a simulated Zephyr scheduler. +extern "C" int64_t k_uptime_get(); diff --git a/tests/support/zephyr/logging/log.h b/tests/support/zephyr/logging/log.h new file mode 100644 index 0000000..5f86999 --- /dev/null +++ b/tests/support/zephyr/logging/log.h @@ -0,0 +1,13 @@ +#pragma once +// Host-only logging boundary. No kernel or driver behavior is emulated here. +#define LOG_MODULE_REGISTER(...) +#define LOG_MODULE_DECLARE(...) +namespace test_support { +template inline void discardLog(const char*, Args...) {} +} +#define LOG_WRN(...) test_support::discardLog(__VA_ARGS__) +#define LOG_ERR(...) test_support::discardLog(__VA_ARGS__) +#define LOG_DBG(...) test_support::discardLog(__VA_ARGS__) +#define LOG_INF(...) test_support::discardLog(__VA_ARGS__) +// Only the explicitly supplied host configuration is supported. +#define IS_ENABLED(value) (value) diff --git a/tests/support/zephyr/random/random.h b/tests/support/zephyr/random/random.h new file mode 100644 index 0000000..2aa86c7 --- /dev/null +++ b/tests/support/zephyr/random/random.h @@ -0,0 +1,4 @@ +#pragma once +#include +// Deterministic boundary for Dispatcher default CAD retry jitter. Not entropy. +inline uint32_t sys_rand32_get() { return 17; } diff --git a/tests/support/zephyr/sys/util.h b/tests/support/zephyr/sys/util.h new file mode 100644 index 0000000..31a32a4 --- /dev/null +++ b/tests/support/zephyr/sys/util.h @@ -0,0 +1,5 @@ +#pragma once +#ifndef IS_ENABLED +#define IS_ENABLED(value) (value) +#endif +#define ARG_UNUSED(value) ((void)(value)) diff --git a/tests/unit/advert_tests.cpp b/tests/unit/advert_tests.cpp new file mode 100644 index 0000000..a326358 --- /dev/null +++ b/tests/unit/advert_tests.cpp @@ -0,0 +1,42 @@ +#include "test.h" +#include +#include +#include +#include + +TEST(advert_golden, "UNIT-ADVERT-001", "Independent advertisement vector") { + const uint8_t golden[] = {0xB1, 0x40, 0x42, 0x0F, 0, 0x80, 0x7B, 0xE1, 0xFF, 0x34, 0x12, 'A'}; + AdvertDataBuilder builder(ADV_TYPE_CHAT, "A", 1.0, -2.0); builder.setFeat1(0x1234); + uint8_t bytes[32] = {}; auto len = builder.encodeTo(bytes); + CHECK(len == sizeof(golden) && std::equal(std::begin(golden), std::end(golden), bytes)); + AdvertDataParser parser(golden, sizeof(golden)); + CHECK(parser.isValid() && parser.getType() == ADV_TYPE_CHAT); + CHECK(parser.getIntLat() == 1000000 && parser.getIntLon() == -2000000); + CHECK(parser.getFeat1() == 0x1234 && std::strcmp(parser.getName(), "A") == 0); +} +TEST(advert_truncation, "UNIT-ADVERT-002", "Optional fields reject truncation and names stay bounded") { + for (unsigned flags = 0; flags < 8; ++flags) { + unsigned required = 1 + ((flags & 1) ? 8 : 0) + ((flags & 2) ? 2 : 0) + ((flags & 4) ? 2 : 0); + for (unsigned len = 0; len <= required; ++len) { + std::vector input(len, 0); if (len) input[0] = flags << 4; + AdvertDataParser parser(input.data(), len); + CHECK(parser.isValid() == (len == required)); + } + } + std::vector oversized(255, 'X'); oversized[0] = ADV_NAME_MASK; + AdvertDataParser parser(oversized.data(), 255); + CHECK(parser.isValid() && std::strlen(parser.getName()) == 31); +} +TEST(utf8_boundaries, "UNIT-UTF8-001", "Complete codepoints only and invalid UTF-8 rejection") { + const char* text = "A\xC3\xA9\xE2\x82\xAC\xF0\x9F\x98\x80"; + const unsigned expected[] = {0, 1, 1, 3, 3, 3, 6, 6, 6, 6, 10}; + for (unsigned limit = 0; limit <= 10; ++limit) CHECK(mesh::validUtf8PrefixLength(text, limit) == expected[limit]); + for (const char* invalid : {"\x80", "\xC0\xAF", "\xED\xA0\x80", "\xF4\x90\x80\x80", "\xE2\x82"}) + CHECK(mesh::validUtf8PrefixLength(invalid, 32) == 0); + CHECK(mesh::validUtf8PrefixLength(nullptr, 32) == 0); + std::string name(30, 'A'); name += "\xC3\xA9"; + AdvertDataBuilder builder(ADV_TYPE_CHAT, name.c_str()); uint8_t buffer[34]; + std::fill(std::begin(buffer), std::end(buffer), 0xCC); + CHECK(builder.encodeTo(buffer + 1) == 31); + CHECK(buffer[0] == 0xCC && buffer[32] == 0xCC && buffer[33] == 0xCC); +} diff --git a/tests/unit/contention_tests.cpp b/tests/unit/contention_tests.cpp new file mode 100644 index 0000000..730bb16 --- /dev/null +++ b/tests/unit/contention_tests.cpp @@ -0,0 +1,48 @@ +#include "test.h" +#include +using namespace mesh; + +TEST(contention_window, "UNIT-CONTENTION-001", "Observation window exact boundary and wrap") { + ContentionTracker c; + CHECK(c.msUntilNextTick(100) == MAINTENANCE_IDLE); + c.trackRetransmit(42, 0xFFFFFFF0u); + CHECK(c.msUntilNextTick(0xFFFFFFF0u) == 10001); + CHECK(c.recordDupeIfTracked(42, uint32_t(0xFFFFFFF0u + 10000u))); + CHECK(!c.recordDupeIfTracked(42, uint32_t(0xFFFFFFF0u + 10001u))); + CHECK(c.extractDupeCount(42) == -1 && c.getContentionEstimate() == 1.0f); +} +TEST(contention_saturation, "UNIT-CONTENTION-002", "Duplicate saturation and exactly-once finalization") { + ContentionTracker c; c.trackRetransmit(1, 100); + for (int i = 0; i < 300; ++i) CHECK(c.recordDupeIfTracked(1, 101)); + CHECK(c.extractDupeCount(1) == 255); CHECK(c.extractDupeCount(1) == -1); + CHECK(c.getContentionEstimate() == 255.0f); + CHECK(!c.recordDupeIfTracked(99, 101)); +} +TEST(contention_caps, "UNIT-CONTENTION-003", "Reactive airtime and hard caps plus disabled backoff") { + ContentionTracker c; c.trackRetransmit(1, 100); c.setBackoffMultiplier(2.0f); + CHECK(c.getReactiveHeadroom(1, 10) == 20); + c.addReactiveExtension(1, 115); CHECK(c.getReactiveHeadroom(1, 10) == 5); + c.addReactiveExtension(1, 5); CHECK(c.getReactiveHeadroom(1, 10) == 0); + c.trackRetransmit(2, 100); c.addReactiveExtension(2, 1999); + CHECK(c.getReactiveHeadroom(2, 1000) == 1); + c.addReactiveExtension(2, 1); CHECK(c.getReactiveHeadroom(2, 1000) == 0); + c.setBackoffMultiplier(0); CHECK(c.getReactiveHeadroom(1, 1000) == 0); +} +TEST(contention_decay, "UNIT-CONTENTION-004", "Warmup eviction and decay independent of tick frequency") { + ContentionTracker c; CHECK(c.getFloodDelayFactor() == 0.5f); + for (unsigned hash = 0; hash < 4; ++hash) { + c.trackRetransmit(hash, 100); CHECK(c.extractDupeCount(hash) == 0); + } + CHECK(c.isWarmedUp() && c.getFloodDelayFactor() == 0.4f); + ContentionTracker d; d.trackRetransmit(100, 100); + for (int i = 0; i < 8; ++i) d.recordDupeIfTracked(100, 101); + CHECK(d.extractDupeCount(100) == 8); + d.tick(300100); CHECK(d.getContentionEstimate() == 8.0f); + d.tick(300101); CHECK(d.getContentionEstimate() == 7.0f); + for (int i = 0; i < 100; ++i) d.tick(300102); + CHECK(d.getContentionEstimate() == 7.0f); + d.tick(305101); CHECK(d.getContentionEstimate() == 6.125f); + ContentionTracker e; + for (unsigned i = 0; i < 25; ++i) e.trackRetransmit(i, 100); + CHECK(e.extractDupeCount(0) == -1 && e.extractDupeCount(1) == 0); +} diff --git a/tests/unit/dispatcher_tests.cpp b/tests/unit/dispatcher_tests.cpp new file mode 100644 index 0000000..7d2c6b5 --- /dev/null +++ b/tests/unit/dispatcher_tests.cpp @@ -0,0 +1,188 @@ +#include "dispatcher_fixture.h" +using namespace test_support; + +TEST(dispatch_send, "UNIT-DISPATCH-001", "Immediate wake delayed dispatch and exactly-once completion") { + Fixture f; f.begin(); + f.dispatcher.sendPacket(f.packet(), 2, 20); + CHECK(f.dispatcher.wakes == std::vector({20})); + f.clock.now = 119; f.dispatcher.loop(); CHECK(f.radio.attempts == 0); + f.clock.now = 120; f.dispatcher.loop(); + CHECK(f.radio.sent == std::vector>({{9, 0, 0xAA}})); + CHECK(f.pool.getFreeCount() == 31 && f.dispatcher.getNumSentFlood() == 0); + f.complete(); f.dispatcher.loop(); + CHECK(f.radio.finished == 1 && f.pool.getFreeCount() == 32); + CHECK(f.dispatcher.getNumSentFlood() == 1 && f.dispatcher.getTotalAirTime() == 100); + f.dispatcher.sendPacket(f.packet(), 2, 0); CHECK(f.dispatcher.wakes.back() == 0); + f.dispatcher.loop(); f.complete(); CHECK(f.pool.getFreeCount() == 32); +} +TEST(dispatch_timeout, "UNIT-DISPATCH-002", "TX timeout releases ownership and next send recovers") { + Fixture f; f.begin(); f.dispatcher.sendPacket(f.packet(), 0); f.dispatcher.loop(); + f.clock.now = 400; f.dispatcher.loop(); CHECK(f.pool.getFreeCount() == 31); + f.clock.now = 401; f.dispatcher.loop(); // expired, but the radio has not concluded: hold + CHECK(f.dispatcher.failures == 0 && f.radio.finished == 0 && f.pool.getFreeCount() == 31); + f.radio.active = false; f.dispatcher.loop(); // the radio's own TX watchdog gave up + CHECK(f.dispatcher.failures == 1 && f.radio.finished == 1 && f.pool.getFreeCount() == 32); + CHECK(f.dispatcher.getNumSentFlood() == 0 && f.dispatcher.getTotalAirTime() == 0); + f.dispatcher.sendPacket(f.packet(), 0); f.dispatcher.loop(); f.complete(); + CHECK(f.dispatcher.getNumSentFlood() == 1 && f.pool.getFreeCount() == 32); +} +TEST(dispatch_refusal, "UNIT-DISPATCH-003", "Driver refusal preserves priority schedule and accounting") { + Fixture f; f.begin(); f.radio.accept = false; + auto p = f.packet(); f.dispatcher.sendPacket(p, 3); f.dispatcher.loop(); + CHECK(f.pool.getOutboundByIdx(0) == p && f.pool.getOutboundSchedule(0) == 200); + CHECK(f.pool.peekNextOutboundPriority(200) == 3); + CHECK(f.dispatcher.getTotalAirTime() == 0 && f.dispatcher.getNumSentFlood() == 0); + f.clock.now = 199; f.dispatcher.loop(); CHECK(f.radio.attempts == 1); + f.radio.accept = true; f.clock.now = 200; f.dispatcher.loop(); f.complete(); + CHECK(f.radio.attempts == 2 && f.pool.getFreeCount() == 32); +} +TEST(dispatch_lbt, "UNIT-DISPATCH-004", "LBT starvation survives interleaved retry-gap wakes") { + Fixture f; f.begin(); f.radio.accept = false; + f.dispatcher.sendPacket(f.packet(), 1); + for (unsigned now = 100; now <= 60200; now += 100) { + f.clock.now = now; f.dispatcher.loop(); + f.clock.now = now + 50; f.dispatcher.loop(); // RX wake while nothing is due + } + CHECK((f.dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT) != 0); + CHECK(f.radio.relaxations == 1 && f.radio.recoveries == 0); + CHECK(f.dispatcher.getNumSentFlood() == 0 && f.pool.getOutboundTotal() == 1); + f.radio.accept = true; f.clock.now = 60300; f.dispatcher.loop(); f.complete(); + CHECK(f.pool.getFreeCount() == 32); + f.radio.accept = false; f.dispatcher.sendPacket(f.packet(), 1); f.dispatcher.loop(); + CHECK(f.radio.relaxations == 1); // successful send ended the old streak + f.radio.accept = true; f.clock.now += 100; f.dispatcher.loop(); f.complete(); +} +TEST(dispatch_final_gate, "UNIT-DISPATCH-005", "Final RX gate requeues without driver call or lost priority") { + Fixture f; f.begin(); f.radio.block_final_gate = true; + auto p = f.packet(); f.dispatcher.sendPacket(p, 3); f.dispatcher.loop(); + CHECK(f.radio.attempts == 0 && f.pool.getOutboundByIdx(0) == p); + CHECK(f.pool.peekNextOutboundPriority(200) == 3 && f.dispatcher.wakes.back() == 100); + f.radio.block_final_gate = false; f.clock.now = 200; f.dispatcher.loop(); f.complete(); + CHECK(f.pool.getFreeCount() == 32); +} +TEST(dispatch_busy_recovery, "UNIT-DISPATCH-006", "Persistent RX busy recovers and resumes sending") { + Fixture f; f.begin(); f.radio.receiving = true; + f.dispatcher.sendPacket(f.packet(), 0); + f.clock.now = 101; f.dispatcher.loop(); CHECK(f.radio.attempts == 0); + f.clock.now = 4102; f.dispatcher.loop(); + CHECK(f.radio.recoveries == 1 && f.radio.attempts == 1); + CHECK((f.dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT) != 0); + f.complete(); CHECK(f.pool.getFreeCount() == 32); +} +TEST(dispatch_budget, "UNIT-DISPATCH-007", "Airtime budget gates and refills at the exact threshold") { + Fixture f; f.begin(10); // 100ms initial budget, 50ms admission threshold + f.dispatcher.sendPacket(f.packet(), 0); f.dispatcher.loop(); f.complete(); + f.dispatcher.sendPacket(f.packet(), 0); f.dispatcher.loop(); + CHECK(f.radio.attempts == 1 && f.dispatcher.wakes.back() == 501); + f.clock.now = 599; f.dispatcher.loop(); CHECK(f.radio.attempts == 1); + f.clock.now = 609; f.dispatcher.loop(); CHECK(f.radio.attempts == 2); + f.complete(); CHECK(f.pool.getFreeCount() == 32); +} +TEST(dispatch_admin_budget, "UNIT-DISPATCH-008", "Due admin packet can transmit after budget depletion") { + Fixture f; f.begin(10); + f.dispatcher.sendPacket(f.packet(), 0); f.dispatcher.loop(); f.complete(); + f.dispatcher.sendPacket(f.packet(PAYLOAD_TYPE_REQ), 0, 10); + f.dispatcher.loop(); CHECK(f.radio.attempts == 1); + f.clock.now += 10; f.dispatcher.loop(); CHECK(f.radio.attempts == 2); + f.complete(); CHECK(f.pool.getFreeCount() == 32); +} +TEST(dispatch_rx_drain, "UNIT-DISPATCH-009", "One wake drains RX ring rejects invalid packets and returns slots") { + Fixture f; f.begin(); + f.radio.incoming = {{8}, {9, 0, 0xAA}, {0x49, 0, 0xAA}, {9, 0xC0, 0xAA}, {10, 0, 0xBB}}; + f.dispatcher.loop(); + CHECK(f.radio.incoming.empty() && f.dispatcher.received.size() == 2); + CHECK(f.dispatcher.received[0]._snr == -9); + CHECK(f.dispatcher.getNumRecvFlood() == 1 && f.dispatcher.getNumRecvDirect() == 1); + CHECK(f.dispatcher.getReceiveAirTime() == 200 && f.pool.getFreeCount() == 32); +} +TEST(dispatch_rx_ownership, "UNIT-DISPATCH-010", "Manual hold and delayed retransmit retain correct ownership") { + Fixture f; f.begin(); f.dispatcher.action = ACTION_MANUAL_HOLD; + f.radio.incoming = {{9, 0, 0xAA}}; f.dispatcher.loop(); + CHECK(f.dispatcher.held.size() == 1 && f.pool.getFreeCount() == 31); + f.dispatcher.releasePacket(f.dispatcher.held[0]); f.dispatcher.held.clear(); + f.dispatcher.action = ACTION_RETRANSMIT_DELAYED(3, 200); + f.radio.incoming = {{9, 0, 0xBB}}; f.dispatcher.loop(); + CHECK(f.pool.getOutboundTotal() == 1 && f.radio.attempts == 0); + CHECK(f.dispatcher.wakes.back() == 200 && f.pool.peekNextOutboundPriority(300) == 3); + f.clock.now = 300; f.dispatcher.loop(); f.complete(); CHECK(f.pool.getFreeCount() == 32); +} +TEST(dispatch_maintenance, "UNIT-DISPATCH-011", "Maintenance never consumes TX completion and reports CAD changes") { + Fixture f; f.begin(); f.dispatcher.sendPacket(f.packet(), 0); f.dispatcher.loop(); + f.radio.completion = true; auto queries = f.radio.completion_queries; + f.dispatcher.maintenanceLoop(); CHECK(f.radio.completion_queries == queries && f.radio.completion); + f.radio.cad_offset = 2; f.dispatcher.maintenanceLoop(); f.dispatcher.maintenanceLoop(); + CHECK(f.dispatcher.offsets == std::vector({2})); + CHECK(f.radio.cad_ticks == 3 && f.radio.radio_ticks == 3); + f.dispatcher.loop(); CHECK(f.dispatcher.getNumSentFlood() == 1 && f.pool.getFreeCount() == 32); +} +TEST(dispatch_stall, "UNIT-DISPATCH-012", "Stall watchdog latches without perpetual immediate deadline") { + Fixture f; f.begin(); f.radio.recv_mode = false; f.dispatcher.maintenanceLoop(); + CHECK(f.dispatcher.msUntilNextMaintenance() == 8000); + f.clock.now += 8001; f.dispatcher.maintenanceLoop(); + CHECK((f.dispatcher.getErrFlags() & ERR_EVENT_STARTRX_TIMEOUT) != 0); + CHECK(f.dispatcher.msUntilNextMaintenance() == mesh::MAINTENANCE_IDLE); + f.radio.maintenance = 75; CHECK(f.dispatcher.msUntilNextMaintenance() == 75); +} +TEST(dispatch_invalid_send, "UNIT-DISPATCH-013", "Invalid outbound packet releases slot without queue wake") { + Fixture f; f.begin(); + auto p = f.packet(); p->path_len = 0xC0; f.dispatcher.sendPacket(p, 0); + p = f.packet(); p->payload_len = 185; f.dispatcher.sendPacket(p, 0); + CHECK(f.pool.getFreeCount() == 32 && f.pool.getOutboundTotal() == 0 && f.dispatcher.wakes.empty()); + std::vector held; + for (int i = 0; i < 32; ++i) held.push_back(f.packet()); + CHECK(f.dispatcher.obtainNewPacket() == nullptr); + CHECK((f.dispatcher.getErrFlags() & ERR_EVENT_FULL) != 0); + for (auto slot : held) f.dispatcher.releasePacket(slot); + f.dispatcher.resetStats(); CHECK(f.dispatcher.getErrFlags() == 0); +} +TEST(dispatch_truncated_header, "UNIT-DISPATCH-014", "Dispatcher parser rejects incomplete route headers before reading") { + Fixture f; f.begin(); + for (uint8_t header : {0x08, 0x09, 0x0A, 0x0B}) { + unsigned required = (header == 0x08 || header == 0x0B) ? 6 : 2; + for (unsigned len = 1; len < required; ++len) { + std::vector input(len, 0); if (len) input[0] = header; + mesh::Packet p; + CHECK(!f.dispatcher.tryParsePacket(&p, input.data(), len)); + } + } + mesh::Packet p; + CHECK(!f.dispatcher.tryParsePacket(&p, nullptr, 0)); + CHECK(!f.dispatcher.tryParsePacket(&p, nullptr, -1)); + std::vector oversized(256, 0); + CHECK(!f.dispatcher.tryParsePacket(&p, oversized.data(), 256)); +} +TEST(dispatch_airtime_reset, "UNIT-DISPATCH-015", "TX accounting uses modem airtime across stats reset while active") { + Fixture f; f.begin(); + f.dispatcher.sendPacket(f.packet(PAYLOAD_TYPE_TXT_MSG, ROUTE_TYPE_DIRECT), 0); + f.dispatcher.loop(); f.clock.now = 350; + f.dispatcher.resetStats(); f.complete(); + CHECK(f.dispatcher.getNumSentDirect() == 1 && f.dispatcher.getNumSentFlood() == 0); + CHECK(f.dispatcher.getTotalAirTime() == 100); // wall time was 250ms + CHECK(f.radio.finished == 1 && f.pool.getFreeCount() == 32); +} +TEST(dispatch_clock_wrap, "UNIT-DISPATCH-016", "Delayed send and TX expiry operate across clock wrap") { + Fixture f; f.clock.now = 0xFFFFFFF0u; f.begin(); + f.dispatcher.sendPacket(f.packet(), 1, 32); + f.clock.now = 0x0Fu; f.dispatcher.loop(); CHECK(f.radio.attempts == 0); + f.clock.now = 0x10u; f.dispatcher.loop(); CHECK(f.radio.attempts == 1); + f.clock.now = 0x10u + 301u; f.dispatcher.loop(); CHECK(f.dispatcher.failures == 0); + f.radio.active = false; f.dispatcher.loop(); + CHECK(f.dispatcher.failures == 1 && f.pool.getFreeCount() == 32); +} +TEST(dispatch_generated_rx, "UNIT-DISPATCH-017", "Generated dispatcher inputs obey version header and path bounds") { + Fixture f; f.begin(); uint32_t seed = 0xD15CA7C4; + for (unsigned len = 0; len <= 255; ++len) for (unsigned n = 0; n < 16; ++n) { + std::vector input(len); + for (auto& b : input) b = nextRandom(seed) >> 24; + mesh::Packet p; + if (f.dispatcher.tryParsePacket(&p, input.data(), len)) { + CHECK(p.getPayloadVer() == 0 && p.getPathHashSize() <= 3); + CHECK(p.getPathByteLen() <= 64 && p.payload_len <= 184); + CHECK(p.getRawLength() == int(len)); + } + } + // Retain the existing Dispatcher contract for a complete, empty-payload frame. + // Packet::readFrom has a stricter nonempty-payload contract; don't conflate them. + const uint8_t empty[] = {9, 0}; mesh::Packet p; + CHECK(f.dispatcher.tryParsePacket(&p, empty, sizeof(empty)) && p.payload_len == 0); +} diff --git a/tests/unit/helper_tests.cpp b/tests/unit/helper_tests.cpp new file mode 100644 index 0000000..d2ebe31 --- /dev/null +++ b/tests/unit/helper_tests.cpp @@ -0,0 +1,58 @@ +#include "test.h" +#include +#include +#include +#include +#include + +TEST(string_capacity, "UNIT-STRING-001", "String copies terminate and respect zero exact and short capacities") { + for (size_t size = 0; size <= 10; ++size) { + std::vector out(size + 2, '#'); + StrHelper::strncpy(out.data() + 1, "abcd", size); + CHECK(out.front() == '#' && out.back() == '#'); + if (size) CHECK(std::string(out.data() + 1) == std::string("abcd").substr(0, size - 1)); + std::fill(out.begin(), out.end(), '#'); + StrHelper::strzcpy(out.data() + 1, "abcd", size); + CHECK(out.front() == '#' && out.back() == '#'); + if (size) { + auto copied = std::min(size_t(4), size - 1); + CHECK(std::string(out.data() + 1) == std::string("abcd").substr(0, copied)); + for (size_t i = copied; i < size; ++i) CHECK(out[i + 1] == 0); + } + } +} +TEST(string_normalization, "UNIT-STRING-002", "Decimal trimming and blank classification") { + const char *input[] = {"250.000", "62.500", "0.0010", "-10.000", "1000", "", "1.25"}; + const char *expected[] = {"250", "62.5", "0.001", "-10", "1000", "", "1.25"}; + for (unsigned i = 0; i < 7; ++i) { + char buffer[32]; std::strcpy(buffer, input[i]); StrHelper::stripTrailingZeros(buffer); + CHECK(std::strcmp(buffer, expected[i]) == 0); + } + CHECK(StrHelper::isBlank(nullptr) && StrHelper::isBlank("") && StrHelper::isBlank(" \t\r\n")); + CHECK(!StrHelper::isBlank(" a ") && !StrHelper::isBlank("0")); +} +TEST(rtc_unique, "UNIT-RTC-001", "Unique timestamps survive repeated and backward wall time") { + struct RTC : mesh::RTCClock { + uint32_t now = 100; + uint32_t getCurrentTime() override { return now; } + void setCurrentTime(uint32_t value) override { now = value; } + } rtc; + CHECK(rtc.getCurrentTimeUnique() == 100); CHECK(rtc.getCurrentTimeUnique() == 101); + rtc.now = 50; CHECK(rtc.getCurrentTimeUnique() == 102); + rtc.now = 200; CHECK(rtc.getCurrentTimeUnique() == 200); +} +TEST(battery_curve, "UNIT-BATTERY-001", "Battery endpoints interpolation multicell scaling and monotonicity") { + CHECK(battery_curve_lookup(&battery_curve_default, 0) == 0); + CHECK(battery_curve_lookup(&battery_curve_default, 3100) == 0); + CHECK(battery_curve_lookup(&battery_curve_default, 4190) == 100); + CHECK(battery_curve_lookup(&battery_curve_default, 3720) == 50); + const uint16_t points[] = {4200, 3600, 3000}; + battery_curve_t twoCell{points, 3, 2}; + CHECK(battery_curve_lookup(&twoCell, 7800) == 75); + CHECK(battery_curve_lookup(&twoCell, 6600) == 25); + unsigned previous = 0; + for (unsigned mv = 0; mv <= 65535; ++mv) { + auto percent = battery_curve_lookup(&battery_curve_default, mv); + CHECK(percent >= previous && percent <= 100); previous = percent; + } +} diff --git a/tests/unit/identity_tests.cpp b/tests/unit/identity_tests.cpp new file mode 100644 index 0000000..51b9225 --- /dev/null +++ b/tests/unit/identity_tests.cpp @@ -0,0 +1,85 @@ +#include "test.h" +#include +#include +#include + +namespace { +// RFC 8032 section 7.1, tests 1 and 2 (public test keys, never device identities). +// https://www.rfc-editor.org/rfc/rfc8032.txt +struct Vector { const char *seed; const char *pub; const char *message; const char *signature; }; +const Vector vectors[] = { + {"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "", + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"}, + {"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72", + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da" + "085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"} +}; +std::vector hex(const char *text) { + std::string value(text); CHECK(value.size() % 2 == 0); + std::vector result; + for (size_t i = 0; i < value.size(); i += 2) result.push_back(std::stoul(value.substr(i, 2), nullptr, 16)); + return result; +} +mesh::LocalIdentity identity(unsigned index = 0) { + mesh::LocalIdentity id; auto seed = hex(vectors[index].seed); id.fromSeed(seed.data()); return id; +} +} +TEST(identity_vectors, "UNIT-IDENTITY-001", "RFC8032 Ed25519 public keys and exact signatures") { + for (const auto& vector : vectors) { + auto seed = hex(vector.seed), pub = hex(vector.pub), msg = hex(vector.message), signature = hex(vector.signature); + mesh::LocalIdentity id; id.fromSeed(seed.data()); + CHECK(std::equal(pub.begin(), pub.end(), id.pub_key)); + uint8_t output[64], empty = 0; + auto data = msg.empty() ? &empty : msg.data(); + id.sign(output, data, int(msg.size())); + CHECK(std::equal(signature.begin(), signature.end(), output)); + CHECK(id.verify(signature.data(), data, int(msg.size()))); + } +} +TEST(identity_tamper, "UNIT-IDENTITY-002", "Reject altered signature message and wrong signer") { + auto id = identity(1); auto signature = hex(vectors[1].signature); + uint8_t msg = 0x72; + for (unsigned byte = 0; byte < 64; ++byte) { + auto bad = signature; bad[byte] ^= 1; CHECK(!id.verify(bad.data(), &msg, 1)); + } + msg ^= 1; CHECK(!id.verify(signature.data(), &msg, 1)); + msg = 0x72; CHECK(!identity(0).verify(signature.data(), &msg, 1)); +} +TEST(identity_storage, "UNIT-IDENTITY-003", "Canonical legacy and private-only storage retain signing identity") { + auto id = identity(); + uint8_t wire[96], storage[96], privateOnly[64]; + CHECK(id.writeTo(wire, sizeof(wire)) == 96 && id.writeToStorage(storage, sizeof(storage)) == 96); + CHECK(id.writeTo(privateOnly, sizeof(privateOnly)) == 64); + CHECK(std::equal(wire, wire + 64, storage + 32)); + CHECK(std::equal(wire + 64, wire + 96, storage)); + for (auto fixture : {std::vector(wire, wire + 96), std::vector(storage, storage + 96), + std::vector(privateOnly, privateOnly + 64)}) { + mesh::LocalIdentity restored; CHECK(restored.readFromStorage(fixture.data(), fixture.size())); + CHECK(restored.matches(id)); + uint8_t sig[64], empty = 0; restored.sign(sig, &empty, 0); + auto expected = hex(vectors[0].signature); CHECK(std::equal(expected.begin(), expected.end(), sig)); + } +} +TEST(identity_storage_reject, "UNIT-IDENTITY-004", "Malformed storage fails without replacing existing identity") { + auto id = identity(), other = identity(1); + uint8_t before[96], bad[96], after[96]; id.writeToStorage(before, 96); other.writeToStorage(bad, 96); + bad[0] ^= 1; + CHECK(!id.readFromStorage(bad, 96)); + for (size_t size : {0u, 31u, 63u, 65u, 95u}) CHECK(!id.readFromStorage(bad, size)); + id.writeToStorage(after, 96); CHECK(std::equal(before, before + 96, after)); + std::array output; output.fill(0xCC); + CHECK(id.writeToStorage(output.data() + 1, 95) == 0); + CHECK(std::all_of(output.begin(), output.end(), [](auto b) { return b == 0xCC; })); + CHECK(id.writeToStorage(output.data() + 1, 96) == 96); + CHECK(output.front() == 0xCC && output.back() == 0xCC); +} +TEST(identity_ecdh, "UNIT-IDENTITY-005", "Real Ed25519-to-X25519 exchange agrees in both directions") { + auto alice = identity(), bob = identity(1); + uint8_t ab[32], ba[32]; alice.calcSharedSecret(ab, bob); bob.calcSharedSecret(ba, alice); + CHECK(std::equal(ab, ab + 32, ba)); + CHECK(std::any_of(ab, ab + 32, [](auto b) { return b != 0; })); + // Pairwise property only: an independent ECDH known-answer fixture remains planned. +} diff --git a/tests/unit/lora_timing_tests.cpp b/tests/unit/lora_timing_tests.cpp new file mode 100644 index 0000000..fed25c1 --- /dev/null +++ b/tests/unit/lora_timing_tests.cpp @@ -0,0 +1,45 @@ +#include "test.h" +#include +#include + +TEST(lora_grace, "UNIT-LORATIME-001", "Preamble grace is (preamble+8) symbols rounded up with an unconfigured default") { + CHECK(zc_lora_preamble_grace_ms(7, 62500, 32) == 82); // 40 * 2^7 / 62.5 kHz = 81.92 ms + CHECK(zc_lora_preamble_grace_ms(12, 125000, 16) == 787); // 24 * 2^12 / 125 kHz = 786.4 ms + CHECK(zc_lora_preamble_grace_ms(7, 500000, 8) == 5); // 4.096 ms rounds up + CHECK(zc_lora_preamble_grace_ms(7, 0, 32) == 1000); + CHECK(zc_lora_preamble_grace_ms(4, 125000, 32) == 1000); + CHECK(zc_lora_preamble_grace_ms(13, 125000, 32) == 1000); +} +TEST(lora_max_payload, "UNIT-LORATIME-002", "Max-payload bound is 255 B at CR 4/8 with LDRO plus 25% and 100 ms") { + CHECK(zc_lora_max_payload_ms(7, 125000) == 1165); // 832 symbols: 852 ms + 213 + 100 + CHECK(zc_lora_max_payload_ms(12, 7812) == 272747); // longest reachable preset, > 262 s + CHECK(zc_lora_max_payload_ms(0, 125000) == 30000 && zc_lora_max_payload_ms(7, 0) == 30000); + uint32_t prev = 0; + for (uint8_t sf = 5; sf <= 12; ++sf) { // monotonic in SF at a fixed bandwidth + uint32_t ms = zc_lora_max_payload_ms(sf, 62500); + CHECK(ms > prev); prev = ms; + } + CHECK(zc_lora_max_payload_ms(9, 62500) > zc_lora_max_payload_ms(9, 125000)); +} +TEST(lora_steps24, "UNIT-LORATIME-003", "Chip timer conversion saturates at 24 bits instead of wrapping") { + CHECK(zc_lora_ms_to_steps24(1000, 32768) == 32768); + CHECK(zc_lora_ms_to_steps24(262143, 64000) == 16777152); // just inside the SX126x field + CHECK(zc_lora_ms_to_steps24(262144, 64000) == 0x00FFFFFFu); + CHECK(zc_lora_ms_to_steps24(272747, 64000) == 0x00FFFFFFu); // SF12/BW7.81 bound: masked it read ~11 s + CHECK(zc_lora_ms_to_steps24(600000, 32768) == 0x00FFFFFFu); + CHECK(zc_lora_ms_to_steps24(0xFFFFFFFFu, 64000) == 0x00FFFFFFu); // no 32-bit overflow on the way + CHECK(zc_lora_ms_to_steps24(0, 32768) == 0); +} +TEST(lr11xx_cad_peak, "UNIT-LORATIME-004", "LR11xx detPeak base table cells symbol adjustment and clamp headroom") { + CHECK(lr11xx_cad_detect_peak(7, 62, 4) == 44); // default preset, sub-125 row + CHECK(lr11xx_cad_detect_peak(8, 125, 4) == 57); + CHECK(lr11xx_cad_detect_peak(5, 500, 8) == 63); + CHECK(lr11xx_cad_detect_peak(12, 250, 2) == 75); // under 4 symbols: no adjustment + CHECK(lr11xx_cad_detect_peak(3, 125, 2) == lr11xx_cad_detect_peak(9, 125, 2)); // out of range -> SF9 + for (uint8_t sf = 5; sf <= 12; ++sf) + for (uint16_t bw : {31, 62, 125, 250, 500}) + for (uint8_t symb : {1, 2, 4, 8, 16}) { + uint8_t p = lr11xx_cad_detect_peak(sf, bw, symb); + CHECK(p >= LR11XX_CAD_PEAK_MIN && p <= LR11XX_CAD_PEAK_MAX); + } +} diff --git a/tests/unit/lpp_tests.cpp b/tests/unit/lpp_tests.cpp new file mode 100644 index 0000000..a5b3c8f --- /dev/null +++ b/tests/unit/lpp_tests.cpp @@ -0,0 +1,50 @@ +// helpers/compat/CayenneLPP.h: upstream's wire format, rounded to nearest. +#include "test.h" +#include "CayenneLPP.h" + +#include + +namespace { +std::vector bytes(CayenneLPP &l) { + return std::vector(l.getBuffer(), l.getBuffer() + l.getSize()); +} +} + +TEST(lpp_rounding, "UNIT-LPP-001", "Values round to the nearest step (3850 mV is 3.85 V, not the library's 3.84)") { + CayenneLPP l(64); + CHECK(l.addVoltage(1, 3850 / 1000.0f) == 4); + CHECK(bytes(l) == std::vector({1, 116, 0x01, 0x81})); // 385 + l.reset(); + l.addTemperature(2, 21.96f); // 220, not 219 + CHECK(bytes(l) == std::vector({2, 103, 0x00, 0xDC})); + l.reset(); + l.addRelativeHumidity(2, 45.3f); // 90.6 -> 91 + CHECK(bytes(l) == std::vector({2, 104, 91})); +} + +TEST(lpp_signed, "UNIT-LPP-002", "Negative values are two's complement in the field, rounded on the magnitude") { + CayenneLPP l(64); + l.addTemperature(1, -2.56f); // -25.6 -> -26 + CHECK(bytes(l) == std::vector({1, 103, 0xFF, 0xE6})); + l.reset(); + l.addCurrent(3, -0.0125f); // -12.5 mA -> -13 + CHECK(bytes(l) == std::vector({3, 117, 0xFF, 0xF3})); + l.reset(); + l.addAltitude(2, -3.4f); + CHECK(bytes(l) == std::vector({2, 121, 0xFF, 0xFD})); +} + +TEST(lpp_gps, "UNIT-LPP-003", "GPS: 0.0001 deg and 0.01 m, 3 bytes each, rounded") { + CayenneLPP l(64); + CHECK(l.addGPS(1, 47.49791f, -19.04023f, 123.456f) == 11); + // 474979, -190402, 12346 + CHECK(bytes(l) == std::vector({1, 136, 0x07, 0x3F, 0x63, 0xFD, 0x18, 0x3E, 0x00, 0x30, 0x3A})); +} + +TEST(lpp_overflow, "UNIT-LPP-004", "A field that does not fit is refused whole; the buffer keeps what fit") { + CayenneLPP l(7); + CHECK(l.addVoltage(1, 4.0f) == 4); + CHECK(l.addVoltage(1, 4.0f) == 0); + CHECK(l.getSize() == 4); + CHECK(l.addRelativeHumidity(1, 50.0f) == 7); +} diff --git a/tests/unit/main.cpp b/tests/unit/main.cpp new file mode 100644 index 0000000..26577ce --- /dev/null +++ b/tests/unit/main.cpp @@ -0,0 +1,24 @@ +#include "test.h" +#include +#include + +int main(int argc, char **argv) +{ + std::set ids; + for (const auto& test : testCases()) { + if (!ids.insert(test.id).second) { std::cerr << "duplicate ID\n"; return 2; } + } + if (argc == 2 && std::string(argv[1]) == "--list") { + for (const auto& test : testCases()) std::cout << test.id << '\t' << test.name << '\n'; + return testCases().empty() ? 2 : 0; + } + if (argc == 3 && std::string(argv[1]) == "--case") { + for (const auto& test : testCases()) if (test.id == std::string(argv[2])) { + try { test.run(); std::cout << "PASS " << test.id << '\n'; return 0; } + catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } + catch (...) { std::cerr << "unknown exception\n"; return 1; } + } + } + std::cerr << "Usage: zephcore_unit --list | --case \n"; + return 2; +} diff --git a/tests/unit/packet_tests.cpp b/tests/unit/packet_tests.cpp new file mode 100644 index 0000000..2627517 --- /dev/null +++ b/tests/unit/packet_tests.cpp @@ -0,0 +1,115 @@ +#include "test.h" +#include "hash_capture.h" +#include +#include +#include +using namespace mesh; + +TEST(path_encodings, "UNIT-PACKET-001", "Exhaust all path encodings") { + for (unsigned encoded = 0; encoded < 256; ++encoded) { + unsigned width = 1 + encoded / 64, hops = encoded % 64; + CHECK(Packet::isValidPathLen(encoded) == (width <= 3 && width * hops <= 64)); + } +} +TEST(empty_paths, "UNIT-PACKET-002", "Zero-hop hash-width regression and bounded copying") { + uint8_t source[64] = {}, output[66]; + std::fill(std::begin(output), std::end(output), 0xCC); + for (uint8_t encoded : {0x00, 0x40, 0x80}) { + CHECK(Packet::copyPath(output + 1, source, 0, encoded) == encoded); + } + CHECK(Packet::copyPath(output + 1, source, 0, 0xC0) == 0); + CHECK(Packet::writePath(output + 1, source, 3, 0x42) == 0); + CHECK(std::all_of(std::begin(output), std::end(output), [](auto b) { return b == 0xCC; })); + CHECK(Packet::writePath(output + 1, source, 4, 0x42) == 4); + CHECK(output[0] == 0xCC && output[5] == 0xCC); +} +TEST(golden_wire, "UNIT-PACKET-003", "Independent transport-direct wire vector") { + // Header = TXT_MSG + TRANSPORT_DIRECT; two little-endian transport codes, + // two 2-byte path hashes, two payload bytes. Authored independently of writeTo. + const uint8_t wire[] = {0x0B, 0x34, 0x12, 0xCD, 0xAB, 0x42, 1, 2, 3, 4, 0xAA, 0xBB}; + Packet p; + CHECK(p.readFrom(wire, sizeof(wire))); + CHECK(p.header == 0x0B && p.transport_codes[0] == 0x1234 && p.transport_codes[1] == 0xABCD); + CHECK(p.getPathHashSize() == 2 && p.getPathHashCount() == 2 && p.payload_len == 2); + CHECK(p.path[3] == 4 && p.payload[0] == 0xAA && p.payload[1] == 0xBB); + uint8_t encoded[256] = {}; + CHECK(p.writeTo(encoded) == sizeof(wire)); + CHECK(std::equal(std::begin(wire), std::end(wire), encoded)); +} +TEST(header_matrix, "UNIT-PACKET-004", "All header bytes and payload boundaries") { + for (unsigned header = 0; header < 256; ++header) { + for (unsigned payload : {0u, 1u, 183u, 184u, 185u}) { + unsigned prefix = ((header & 3) == 0 || (header & 3) == 3) ? 6 : 2; + std::vector wire(prefix + payload, 0); + wire[0] = header; + Packet p; + CHECK(p.readFrom(wire.data(), wire.size()) == (payload >= 1 && payload <= 184)); + if (payload >= 1 && payload <= 184) { + CHECK(p.getRouteType() == (header & 3)); + CHECK(p.getPayloadType() == ((header >> 2) & 15)); + CHECK(p.getPayloadVer() == (header >> 6)); + CHECK(p.payload_len == payload); + } + } + } +} +TEST(truncation, "UNIT-PACKET-005", "Reject truncated headers and paths") { + const uint8_t wire[] = {0x0B, 0, 0, 0, 0, 0x42, 1, 2, 3, 4, 0xAA}; + for (unsigned length = 0; length < sizeof(wire); ++length) { + std::vector input(wire, wire + length); // exact input extent for ASan + Packet p; + CHECK(!p.readFrom(input.data(), length)); + } + Packet p; + CHECK(p.readFrom(wire, sizeof(wire))); +} +TEST(generated_roundtrip, "UNIT-PACKET-006", "Seeded valid packets preserve wire fields") { + uint32_t seed = 0x5EED1234; + for (int i = 0; i < 2000; ++i) { + Packet p; + p.header = nextRandom(seed) & 255; + unsigned width = 1 + nextRandom(seed) % 3; + unsigned maxHops = std::min(63u, 64u / width); + p.setPathHashSizeAndCount(width, nextRandom(seed) % (maxHops + 1)); + p.transport_codes[0] = nextRandom(seed); p.transport_codes[1] = nextRandom(seed); + p.payload_len = 1 + nextRandom(seed) % 184; + for (auto& b : p.path) b = nextRandom(seed) >> 24; + for (auto& b : p.payload) b = nextRandom(seed) >> 24; + uint8_t wire[257]; std::fill(std::begin(wire), std::end(wire), 0xCC); + auto size = p.writeTo(wire + 1); + CHECK(size == p.getRawLength()); + CHECK(wire[0] == 0xCC && wire[size + 1] == 0xCC); + Packet decoded; + CHECK(decoded.readFrom(wire + 1, size)); + CHECK(decoded.header == p.header && decoded.path_len == p.path_len); + CHECK(decoded.payload_len == p.payload_len); + CHECK(std::equal(p.path, p.path + p.getPathByteLen(), decoded.path)); + CHECK(std::equal(p.payload, p.payload + p.payload_len, decoded.payload)); + CHECK(decoded.transport_codes[0] == (p.hasTransportCodes() ? p.transport_codes[0] : 0)); + CHECK(decoded.transport_codes[1] == (p.hasTransportCodes() ? p.transport_codes[1] : 0)); + } +} +TEST(arbitrary_packets, "UNIT-PACKET-007", "Seeded arbitrary input parser invariants") { + uint32_t seed = 0xBADCAFE; + for (unsigned len = 0; len <= 255; ++len) for (int n = 0; n < 32; ++n) { + std::vector input(len); + for (auto& b : input) b = nextRandom(seed) >> 24; + Packet p; + if (p.readFrom(input.data(), len)) { + CHECK(p.getPathHashSize() <= 3 && p.getPathByteLen() <= 64); + CHECK(p.payload_len >= 1 && p.payload_len <= 184); + CHECK(p.getRawLength() == int(len)); + } + } +} +TEST(hash_preimage, "UNIT-PACKET-008", "Ordinary and TRACE hash input contracts (not crypto)") { + Packet p; p.header = (PAYLOAD_TYPE_TXT_MSG << 2) | ROUTE_TYPE_FLOOD; + p.path_len = 1; p.path[0] = 7; p.payload_len = 2; p.payload[0] = 0xAA; p.payload[1] = 0xBB; + uint8_t hash[8]; p.calculatePacketHash(hash); + CHECK(test_support::hash_input == std::vector({2, 0xAA, 0xBB})); + CHECK(test_support::hash_output_size == 8); + p.path_len = 2; p.path[1] = 8; p.calculatePacketHash(hash); + CHECK(test_support::hash_input == std::vector({2, 0xAA, 0xBB})); + p.header = (PAYLOAD_TYPE_TRACE << 2) | ROUTE_TYPE_DIRECT; p.calculatePacketHash(hash); + CHECK(test_support::hash_input == std::vector({9, 2, 0xAA, 0xBB})); +} diff --git a/tests/unit/policy_tests.cpp b/tests/unit/policy_tests.cpp new file mode 100644 index 0000000..9762b45 --- /dev/null +++ b/tests/unit/policy_tests.cpp @@ -0,0 +1,49 @@ +#include "test.h" +#include +#include +#include +using namespace mesh; + +TEST(reply_routes, "UNIT-POLICY-001", "Reply routing and scope truth tables") { + const ReplyRoute routes[] = {REPLY_ROUTE_FLOOD, REPLY_ROUTE_DIRECT_OUT_PATH, + REPLY_ROUTE_DIRECT_SUPPLIED, REPLY_ROUTE_DIRECT_SUPPLIED, + REPLY_ROUTE_PATH_RETURN, REPLY_ROUTE_PATH_RETURN, REPLY_ROUTE_PATH_RETURN, REPLY_ROUTE_PATH_RETURN}; + const ReplyScope scopes[] = {REPLY_SCOPE_NONE, REPLY_SCOPE_DEFAULT, REPLY_SCOPE_NONE, + REPLY_SCOPE_NONE, REPLY_SCOPE_REQUEST, REPLY_SCOPE_REQUEST, REPLY_SCOPE_REQUEST, REPLY_SCOPE_REQUEST}; + for (int bits = 0; bits < 8; ++bits) { + CHECK(chooseReplyRoute(bits & 4, bits & 2, bits & 1) == routes[bits]); + CHECK(chooseReplyScope(bits & 4, bits & 2, bits & 1) == scopes[bits]); + } +} +TEST(hop_limits, "UNIT-POLICY-002", "Scoped unscoped and advert hop boundaries") { + Packet p; + for (uint8_t route : {ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD}) { + for (uint8_t type : {PAYLOAD_TYPE_ADVERT, PAYLOAD_TYPE_TXT_MSG}) { + p.header = route | (type << 2); + unsigned limit = type == PAYLOAD_TYPE_ADVERT ? 2 : (route == ROUTE_TYPE_FLOOD ? 3 : 5); + for (unsigned hops = 0; hops <= 6; ++hops) { + p.setPathHashSizeAndCount(3, hops); + CHECK(isFloodHopLimitExceeded(&p, 5, 3, 2) == (hops >= limit)); + } + } + } + p.header = ROUTE_TYPE_FLOOD; p.path_len = 0; + CHECK(isFloodHopLimitExceeded(&p, 5, 0, 2)); +} +TEST(rate_window, "UNIT-RATE-001", "Rate window threshold expiry and reset") { + RateLimiter limiter(2, 10); + CHECK(limiter.allow(100)); CHECK(limiter.allow(101)); + CHECK(!limiter.allow(109)); CHECK(limiter.allow(110)); + CHECK(limiter.allow(111)); CHECK(!limiter.allow(112)); + limiter.reset(); CHECK(limiter.allow(113)); CHECK(limiter.allow(114)); + CHECK(!limiter.allow(115)); +} +TEST(maintenance_wrap, "UNIT-TIME-001", "Maintenance deadlines across uint32 wrap") { + CHECK(maintenanceUntil(100, 101) == 1); + CHECK(maintenanceUntil(100, 100) == 0); + CHECK(maintenanceUntil(100, 99) == 0); + CHECK(maintenanceUntil(0xFFFFFFF0u, 0x10u) == 32); + CHECK(maintenanceUntil(0x10u, 0xFFFFFFF0u) == 0); + CHECK(maintenanceSooner(MAINTENANCE_IDLE, 32) == 32); + CHECK(maintenanceSooner(0, MAINTENANCE_IDLE) == 0); +} diff --git a/tests/unit/pool_tests.cpp b/tests/unit/pool_tests.cpp new file mode 100644 index 0000000..a6c36ba --- /dev/null +++ b/tests/unit/pool_tests.cpp @@ -0,0 +1,65 @@ +#include "test.h" +#include +#include +using namespace mesh; + +TEST(pool_capacity, "UNIT-POOL-001", "Exhaust reclaim and reuse all pool slots") { + StaticPoolPacketManager pool; + std::set allocated; + for (int i = 0; i < 32; ++i) { + auto p = pool.allocNew(); CHECK(p != nullptr); CHECK(allocated.insert(p).second); + } + CHECK(pool.allocNew() == nullptr && pool.getFreeCount() == 0); + for (auto p : allocated) pool.free(p); + CHECK(pool.getFreeCount() == 32); + for (int i = 0; i < 1000; ++i) { + auto p = pool.allocNew(); CHECK(allocated.count(p) == 1); pool.free(p); + CHECK(pool.getFreeCount() == 32); + } + pool.free(nullptr); CHECK(pool.getFreeCount() == 32); +} +TEST(pool_priority, "UNIT-POOL-002", "Only due packets dispatch in priority and tie order") { + StaticPoolPacketManager pool; + auto a = pool.allocNew(), b = pool.allocNew(), c = pool.allocNew(); + pool.queueOutbound(a, 2, 100); pool.queueOutbound(b, 1, 101); pool.queueOutbound(c, 2, 100); + CHECK(pool.getNextOutbound(99) == nullptr); + CHECK(pool.getOutboundCount(100) == 2 && pool.peekNextOutboundPriority(100) == 2); + CHECK(pool.getNextOutbound(101) == b); pool.free(b); + CHECK(pool.getNextOutbound(101) == a); pool.free(a); + CHECK(pool.getNextOutbound(101) == c); pool.free(c); + CHECK(pool.getOutboundTotal() == 0 && pool.getFreeCount() == 32); +} +TEST(pool_reschedule, "UNIT-POOL-003", "Reschedule remove and dispatch across clock wrap") { + StaticPoolPacketManager pool; + auto p = pool.allocNew(); pool.queueOutbound(p, 0, 0xFFFFFFF0u); + CHECK(pool.getOutboundByIdx(0) == p); + CHECK(pool.rescheduleOutbound(0, 0x10u)); CHECK(pool.getOutboundSchedule(0) == 0x10u); + CHECK(pool.getNextOutbound(0xFFFFFFF0u) == nullptr); + CHECK(pool.getNextOutbound(0x0Fu) == nullptr); + CHECK(pool.getNextOutbound(0x10u) == p); pool.free(p); + p = pool.allocNew(); pool.queueOutbound(p, 2, 500); + CHECK(pool.removeOutboundByIdx(0) == p); pool.free(p); + CHECK(!pool.rescheduleOutbound(0, 1)); CHECK(pool.removeOutboundByIdx(0) == nullptr); + CHECK(pool.getFreeCount() == 32); +} +TEST(pool_sequence, "UNIT-POOL-004", "Seeded pool ownership conservation") { + StaticPoolPacketManager pool; + // Initialize the process-global pool through its public allocation API. + auto initial = pool.allocNew(); pool.free(initial); + std::vector held; + uint32_t seed = 0x12345678; + for (unsigned step = 0; step < 5000; ++step) { + switch (nextRandom(seed) % 3) { + case 0: if (auto p = pool.allocNew()) held.push_back(p); break; + case 1: if (!held.empty()) { pool.queueOutbound(held.back(), nextRandom(seed) % 4, 0); held.pop_back(); } break; + case 2: if (auto p = pool.getNextOutbound(0)) pool.free(p); break; + } + CHECK(pool.getFreeCount() + pool.getOutboundTotal() + int(held.size()) == 32); + std::set owned(held.begin(), held.end()); + CHECK(owned.size() == held.size()); + for (int i = 0; i < pool.getOutboundTotal(); ++i) CHECK(owned.insert(pool.getOutboundByIdx(i)).second); + } + for (auto p : held) pool.free(p); + while (auto p = pool.getNextOutbound(0)) pool.free(p); + CHECK(pool.getFreeCount() == 32); +} diff --git a/tests/unit/prefs_codec_tests.cpp b/tests/unit/prefs_codec_tests.cpp new file mode 100644 index 0000000..c8f26d8 --- /dev/null +++ b/tests/unit/prefs_codec_tests.cpp @@ -0,0 +1,299 @@ +// Prefs byte codecs (helpers/PrefsCodec.cpp): both on-disk layouts pinned +// against an offset table written independently of the codec, from the byte +// maps (memory prefs-format.md; offset comments in the old stores). +#include "test.h" +#include "PrefsCodec.h" + +#include +#include +#include + +namespace { + +struct Field { size_t file_off; size_t size; size_t struct_off; }; +#define F(off, member) { off, sizeof(NodePrefs::member), offsetof(NodePrefs, member) } + +// Companion /lfs/new_prefs, 272 bytes. Pads at 36-39 and 79. +const Field kCompanion[] = { + F(0, airtime_factor), F(4, node_name), F(40, node_lat), F(48, node_lon), F(56, freq), + F(60, sf), F(61, cr), F(62, client_repeat), F(63, manual_add_contacts), F(64, bw), + F(68, tx_power_dbm), F(69, telemetry_mode_base), F(70, telemetry_mode_loc), + F(71, telemetry_mode_env), F(72, rx_delay_base), F(76, advert_loc_policy), + F(77, multi_acks), F(78, path_hash_mode), F(80, ble_pin), F(84, buzzer_quiet), + F(85, gps_enabled), F(86, gps_interval), F(90, autoadd_config), F(91, autoadd_max_hops), + F(92, rx_boost), F(93, leds_disabled), F(94, _reserved_apc_enabled), + F(95, _reserved_apc_margin), { 96, 31, offsetof(NodePrefs, default_scope_name) }, + F(127, default_scope_key), F(143, ble_disabled), F(144, display_brightness), + F(145, wake_on_msg), F(146, screen_off_secs), F(148, auto_shutdown_mv), + F(150, rx_duty_cycle), F(151, meshtimesync), F(152, v_contact_enabled), + F(153, v_battery_alert_mv), F(155, cad_auto), F(156, cad_offset), F(157, probe_interval), + F(158, cad_busycap), F(159, adc_multiplier), F(163, extra_sf), F(166, v_contact_flags), + F(167, fem_rxgain), F(168, display_rotate), F(169, input_rotate), F(170, cad_base), + F(171, tz_offset), F(172, leds_radio_mode), F(173, leds_hb_mode), F(174, wifi_enabled), + F(175, wifi_ssid), F(208, wifi_pwd), +}; + +// Server /lfs/repeater/prefs, 311 bytes. Offset 120 is leds_disabled as a +// magic byte (checked separately); pads at 36-39, 79, 123, 127-151 (bridge), +// 153-155. +const Field kServer[] = { + F(0, airtime_factor), F(4, node_name), F(40, node_lat), F(48, node_lon), F(56, password), + F(72, freq), F(76, tx_power_dbm), F(77, disable_fwd), F(78, advert_interval), + F(80, rx_delay_base), F(84, tx_delay_factor), F(88, guest_password), + F(104, direct_tx_delay_factor), F(108, backoff_multiplier), F(112, sf), F(113, cr), + F(114, allow_read_only), F(115, multi_acks), F(116, bw), F(121, path_hash_mode), + F(122, loop_detect), F(124, flood_max), F(125, flood_advert_interval), + F(126, interference_threshold), F(152, powersaving_enabled), F(156, gps_enabled), + F(157, gps_interval), F(161, advert_loc_policy), F(162, discovery_mod_timestamp), + F(166, adc_multiplier), F(170, owner_info), F(290, rx_boost), F(291, rx_duty_cycle), + F(292, _reserved_apc_enabled), F(293, _reserved_apc_margin), F(294, flood_max_unscoped), + F(295, flood_max_advert), F(296, meshtimesync), F(297, cad_auto), F(298, cad_offset), + F(299, probe_interval), F(300, cad_busycap), F(301, extra_sf), F(304, fem_rxgain), + F(305, display_rotate), F(306, input_rotate), F(307, cad_base), F(308, tz_offset), + F(309, leds_radio_mode), F(310, leds_hb_mode), +}; +#undef F + +const uint8_t *field(const NodePrefs &p, const Field &f) { + return reinterpret_cast(&p) + f.struct_off; +} +uint8_t *field(NodePrefs &p, const Field &f) { + return reinterpret_cast(&p) + f.struct_off; +} + +NodePrefs defaults() { + NodePrefs p; + initNodePrefs(&p); + return p; +} + +// Every serialized field different from its initNodePrefs() default, all in range. +NodePrefs sample() { + NodePrefs p = defaults(); + p.airtime_factor = 3.5f; + strcpy(p.node_name, "Node-Test"); + p.node_lat = 47.5; p.node_lon = 19.04; + strcpy(p.password, "adminpw"); strcpy(p.guest_password, "guestpw"); + p.freq = 869.525f; p.sf = 9; p.cr = 6; p.bw = 125.0f; p.tx_power_dbm = 14; + p.disable_fwd = 1; p.advert_interval = 30; p.flood_advert_interval = 12; + p.rx_delay_base = 1.5f; p.tx_delay_factor = 0.7f; p.direct_tx_delay_factor = 0.4f; + p.backoff_multiplier = 0.6f; p.allow_read_only = 1; p.multi_acks = 1; + p.flood_max = 20; p.flood_max_unscoped = 10; p.flood_max_advert = 4; + p.interference_threshold = 7; p.leds_disabled = 1; p.leds_radio_mode = LEDS_RADIO_RX; + p.leds_hb_mode = LEDS_HB_UNREAD; p.powersaving_enabled = 1; p.gps_enabled = 1; + p.gps_interval = 600; p.advert_loc_policy = ADVERT_LOC_SHARE; p.discovery_mod_timestamp = 123456; + p.adc_multiplier = 1.25f; strcpy(p.owner_info, "owner info"); + p.rx_boost = 0; p.fem_rxgain = 0; p.rx_duty_cycle = 1; + p._reserved_apc_enabled = 0x11; p._reserved_apc_margin = 0x22; + p.meshtimesync = 1; p.cad_auto = 0; p.cad_offset = -3; p.probe_interval = 30; p.cad_busycap = 40; + p.extra_sf[0] = 10; p.extra_sf[1] = 11; p.extra_sf[2] = 0; + p.display_rotate = 1; p.input_rotate = 1; p.tz_offset = -5; p.cad_base = 21; + p.manual_add_contacts = 1; p.telemetry_mode_base = 1; p.telemetry_mode_loc = 2; + p.telemetry_mode_env = 1; p.ble_pin = 123456; p.buzzer_quiet = 1; p.autoadd_config = 0x1E; + p.client_repeat = 1; p.path_hash_mode = 2; p.autoadd_max_hops = 5; p.loop_detect = 3; + strcpy(p.default_scope_name, "#hu"); + for (int i = 0; i < 16; i++) p.default_scope_key[i] = (uint8_t)(i + 1); + p.ble_disabled = 1; p.display_brightness = 50; p.wake_on_msg = 0; p.screen_off_secs = 60; + p.auto_shutdown_mv = 3300; p.v_contact_enabled = 0; p.v_battery_alert_mv = 3500; + p.v_contact_flags = 0x81; p.wifi_enabled = 0; + strcpy(p.wifi_ssid, "MyNet"); strcpy(p.wifi_pwd, "secret"); + return p; +} + +bool zeros(const uint8_t *b, size_t from, size_t to) { + for (size_t i = from; i < to; i++) if (b[i] != 0) return false; + return true; +} + +template +void checkLayout(const uint8_t *buf, const NodePrefs &p, const Field (&table)[N]) { + for (const Field &f : table) { + if (std::memcmp(buf + f.file_off, field(p, f), f.size) != 0) + throw std::runtime_error("field at file offset " + std::to_string(f.file_off)); + } +} + +// What a decode of the first len bytes must give: the caller's defaults, with +// each field wholly inside len taken from the file. +template +NodePrefs expectShort(const NodePrefs &file_p, size_t len, const Field (&table)[N]) { + NodePrefs e = defaults(); + for (const Field &f : table) + if (f.file_off + f.size <= len) std::memcpy(field(e, f), field(file_p, f), f.size); + return e; +} + +} // namespace + +TEST(prefs_companion_layout, "UNIT-PREFS-001", "Companion prefs encode to the documented 272-byte layout") { + NodePrefs p = sample(); + uint8_t buf[COMPANION_PREFS_SIZE + 8]; + std::memset(buf, 0xEE, sizeof(buf)); + CHECK(companionPrefsEncode(p, buf, sizeof(buf)) == COMPANION_PREFS_SIZE); + checkLayout(buf, p, kCompanion); + CHECK(zeros(buf, 36, 40)); + CHECK(buf[79] == 0); + CHECK(buf[COMPANION_PREFS_SIZE] == 0xEE); + CHECK(companionPrefsEncode(p, buf, COMPANION_PREFS_SIZE - 1) == 0); +} + +TEST(prefs_companion_roundtrip, "UNIT-PREFS-002", "Companion prefs decode what they encode") { + NodePrefs p = sample(); + uint8_t a[COMPANION_PREFS_SIZE], b[COMPANION_PREFS_SIZE]; + companionPrefsEncode(p, a, sizeof(a)); + NodePrefs q = defaults(); + CHECK(companionPrefsDecode(q, a, sizeof(a))); + companionPrefsEncode(q, b, sizeof(b)); + CHECK(std::memcmp(a, b, sizeof(a)) == 0); +} + +TEST(prefs_companion_short, "UNIT-PREFS-003", "Short companion files keep the caller's defaults past the end") { + NodePrefs p = sample(); + uint8_t full[COMPANION_PREFS_SIZE]; + companionPrefsEncode(p, full, sizeof(full)); + // Every field boundary: each length the format has had ends on one. + const size_t lens[] = { 91, 92, 93, 94, 95, 96, 127, 143, 144, 145, 146, 148, 150, 151, + 152, 153, 155, 156, 157, 158, 159, 163, 166, 167, 168, 169, 170, + 171, 172, 173, 174, 175, 208, 272 }; + for (size_t len : lens) { + NodePrefs got = defaults(); + CHECK(companionPrefsDecode(got, full, len)); + NodePrefs want = expectShort(p, len, kCompanion); + uint8_t g[COMPANION_PREFS_SIZE], w[COMPANION_PREFS_SIZE]; + companionPrefsEncode(got, g, sizeof(g)); + companionPrefsEncode(want, w, sizeof(w)); + if (std::memcmp(g, w, sizeof(g)) != 0) + throw std::runtime_error("length " + std::to_string(len)); + } +} + +TEST(prefs_companion_reject, "UNIT-PREFS-004", "Companion decode rejects short or foreign data and leaves prefs untouched") { + NodePrefs p = sample(); + uint8_t buf[COMPANION_PREFS_SIZE]; + companionPrefsEncode(p, buf, sizeof(buf)); + NodePrefs got = defaults(); + uint8_t before[sizeof(NodePrefs)]; + std::memcpy(before, &got, sizeof(got)); + CHECK(!companionPrefsDecode(got, buf, 89)); + CHECK(std::memcmp(before, &got, sizeof(got)) == 0); + buf[60] = 1; // sf out of range: not our layout + CHECK(!companionPrefsDecode(got, buf, sizeof(buf))); + CHECK(std::memcmp(before, &got, sizeof(got)) == 0); + companionPrefsEncode(p, buf, sizeof(buf)); + float bad = 5000.0f; + std::memcpy(&buf[56], &bad, 4); + CHECK(!companionPrefsDecode(got, buf, sizeof(buf))); + CHECK(std::memcmp(before, &got, sizeof(got)) == 0); +} + +TEST(prefs_companion_corrupt, "UNIT-PREFS-005", "Corrupt companion bytes fall back to the sanitizeNodePrefs defaults") { + NodePrefs p = sample(); + uint8_t buf[COMPANION_PREFS_SIZE]; + companionPrefsEncode(p, buf, sizeof(buf)); + buf[150] = 5; // rx_duty_cycle + buf[151] = 9; // meshtimesync + buf[152] = 9; // v_contact_enabled + buf[155] = 7; // cad_auto + buf[156] = 100; // cad_offset + buf[157] = 3; // probe_interval + buf[158] = 200; // cad_busycap + float nan = NAN; + std::memcpy(&buf[159], &nan, 4); // adc_multiplier + buf[171] = 50; // tz_offset + buf[172] = 9; // leds_radio_mode + NodePrefs got = defaults(); + CHECK(companionPrefsDecode(got, buf, sizeof(buf))); + CHECK(got.rx_duty_cycle == 0); + CHECK(got.meshtimesync == 0); + CHECK(got.v_contact_enabled == 1); + CHECK(got.cad_auto == 1); // the initNodePrefs() default, not "off" + CHECK(got.cad_offset == 0); + CHECK(got.probe_interval == 10); + CHECK(got.cad_busycap == 90); + CHECK(got.adc_multiplier == 0.0f); + CHECK(got.tz_offset == 0); + CHECK(got.leds_radio_mode == 0); +} + +TEST(prefs_server_layout, "UNIT-PREFS-006", "Server prefs encode to the documented 311-byte layout") { + NodePrefs p = sample(); + uint8_t buf[SERVER_PREFS_SIZE + 8]; + std::memset(buf, 0xEE, sizeof(buf)); + CHECK(serverPrefsEncode(p, buf, sizeof(buf)) == SERVER_PREFS_SIZE); + checkLayout(buf, p, kServer); + CHECK(buf[120] == LEDS_PREF_OFF); + CHECK(zeros(buf, 36, 40)); + CHECK(buf[79] == 0 && buf[123] == 0); + CHECK(zeros(buf, 127, 152)); + CHECK(zeros(buf, 153, 156)); + CHECK(buf[SERVER_PREFS_SIZE] == 0xEE); + p.leds_disabled = 0; + serverPrefsEncode(p, buf, sizeof(buf)); + CHECK(buf[120] == LEDS_PREF_ON); + CHECK(serverPrefsEncode(p, buf, SERVER_PREFS_SIZE - 1) == 0); +} + +TEST(prefs_server_roundtrip, "UNIT-PREFS-007", "Server prefs decode what they encode") { + NodePrefs p = sample(); + uint8_t a[SERVER_PREFS_SIZE], b[SERVER_PREFS_SIZE]; + serverPrefsEncode(p, a, sizeof(a)); + NodePrefs q = defaults(); + serverPrefsDecode(q, a, sizeof(a)); + serverPrefsEncode(q, b, sizeof(b)); + CHECK(std::memcmp(a, b, sizeof(a)) == 0); +} + +TEST(prefs_server_short, "UNIT-PREFS-008", "Short server files keep the caller's values past the end") { + NodePrefs p = sample(); + uint8_t full[SERVER_PREFS_SIZE]; + serverPrefsEncode(p, full, sizeof(full)); + // Every length the format has had. + const size_t lens[] = { 290, 292, 294, 296, 297, 300, 301, 304, 305, 307, 308, 309, 311 }; + for (size_t len : lens) { + NodePrefs got = defaults(); + serverPrefsDecode(got, full, len); + NodePrefs want = expectShort(p, len, kServer); + want.leds_disabled = p.leds_disabled; // offset 120, inside every length + uint8_t g[SERVER_PREFS_SIZE], w[SERVER_PREFS_SIZE]; + serverPrefsEncode(got, g, sizeof(g)); + serverPrefsEncode(want, w, sizeof(w)); + if (std::memcmp(g, w, sizeof(g)) != 0) + throw std::runtime_error("length " + std::to_string(len)); + } +} + +TEST(prefs_server_legacy, "UNIT-PREFS-009", "Server LED magic byte and implausible radio params") { + NodePrefs p = sample(); + uint8_t buf[SERVER_PREFS_SIZE]; + serverPrefsEncode(p, buf, sizeof(buf)); + const uint8_t legacy_agc[] = { 0, 15, 0xA0, 0xFF }; // an old AGC interval means "on" + for (uint8_t v : legacy_agc) { + buf[120] = v; + NodePrefs got = defaults(); + serverPrefsDecode(got, buf, sizeof(buf)); + CHECK(got.leds_disabled == 0); + } + buf[120] = LEDS_PREF_OFF; + buf[112] = 2; // sf out of range + NodePrefs got = defaults(); + serverPrefsDecode(got, buf, sizeof(buf)); + CHECK(got.leds_disabled == 1); + CHECK(got.sf == mesh::LoRaConfig::SPREADING_FACTOR); + CHECK(got.freq == mesh::LoRaConfig::FREQ_MHZ); + CHECK(got.bw == mesh::LoRaConfig::BANDWIDTH_KHZ); + CHECK(got.cr == mesh::LoRaConfig::CODING_RATE); + CHECK(got.tx_power_dbm == mesh::LoRaConfig::TX_POWER_DBM); + CHECK(std::strcmp(got.node_name, "Node-Test") == 0); // the rest still loads + CHECK(got.flood_max == 20); +} + +TEST(prefs_gps_interval, "UNIT-PREFS-010", "GPS interval: one clamp, and a corrupt value is not turned into always-on") { + CHECK(clampGpsInterval(0) == 0); // always on stays a choice + CHECK(clampGpsInterval(5) == GPS_INTERVAL_MIN_SEC); + CHECK(clampGpsInterval(300) == 300); + CHECK(clampGpsInterval(GPS_INTERVAL_MAX_SEC + 1) == GPS_INTERVAL_MAX_SEC); + NodePrefs p; initNodePrefs(&p); + p.gps_interval = 0xFFFFFFFFu; // garbage in a prefs file + sanitizeNodePrefs(&p); + CHECK(p.gps_interval == GPS_INTERVAL_MAX_SEC); +} diff --git a/tests/unit/prefs_json_tests.cpp b/tests/unit/prefs_json_tests.cpp new file mode 100644 index 0000000..6cd807e --- /dev/null +++ b/tests/unit/prefs_json_tests.cpp @@ -0,0 +1,193 @@ +// prefs.json (helpers/PrefsJson.cpp over upstream's ConfigSerializer). +#include "test.h" +#include "PrefsJson.h" +#include "PrefsCodec.h" + +#include +#include + +namespace { + +class MemStream : public Stream { +public: + std::string data; + size_t pos = 0; + explicit MemStream(std::string s = "") : data(std::move(s)) {} + size_t write(uint8_t c) override { data.push_back((char)c); return 1; } + int available() override { return (int)(data.size() - pos); } + int read() override { return pos < data.size() ? (uint8_t)data[pos++] : -1; } + int peek() override { return pos < data.size() ? (uint8_t)data[pos] : -1; } +}; + +NodePrefs defaults() { NodePrefs p; initNodePrefs(&p); return p; } + +// Same field values as the codec tests: every serialized field off its default. +NodePrefs sample() { + NodePrefs p = defaults(); + p.airtime_factor = 3.5f; strcpy(p.node_name, "Node-Test"); + p.node_lat = 47.5; p.node_lon = 19.04; + strcpy(p.password, "adminpw"); strcpy(p.guest_password, "guestpw"); + p.freq = 869.525f; p.sf = 9; p.cr = 6; p.bw = 125.0f; p.tx_power_dbm = 14; + p.disable_fwd = 1; p.advert_interval = 30; p.flood_advert_interval = 12; + p.rx_delay_base = 1.5f; p.tx_delay_factor = 0.7f; p.direct_tx_delay_factor = 0.4f; + p.backoff_multiplier = 0.6f; p.allow_read_only = 1; p.multi_acks = 1; + p.flood_max = 20; p.flood_max_unscoped = 10; p.flood_max_advert = 4; + p.interference_threshold = 7; p.leds_disabled = 1; p.leds_radio_mode = LEDS_RADIO_RX; + p.leds_hb_mode = LEDS_HB_UNREAD; p.powersaving_enabled = 1; p.gps_enabled = 1; + p.gps_interval = 600; p.advert_loc_policy = ADVERT_LOC_SHARE; p.discovery_mod_timestamp = 123456; + p.adc_multiplier = 1.25f; strcpy(p.owner_info, "owner info"); + p.rx_boost = 0; p.fem_rxgain = 0; p.rx_duty_cycle = 1; + p.meshtimesync = 1; p.cad_auto = 0; p.cad_offset = -3; p.probe_interval = 30; p.cad_busycap = 40; + p.extra_sf[0] = 10; p.extra_sf[1] = 11; p.extra_sf[2] = 0; + p.display_rotate = 1; p.input_rotate = 1; p.tz_offset = -5; p.cad_base = 21; + p.manual_add_contacts = 1; p.telemetry_mode_base = 1; p.telemetry_mode_loc = 2; + p.telemetry_mode_env = 1; p.ble_pin = 123456; p.buzzer_quiet = 1; p.autoadd_config = 0x1E; + p.client_repeat = 1; p.path_hash_mode = 2; p.autoadd_max_hops = 5; p.loop_detect = 3; + strcpy(p.default_scope_name, "#hu"); + for (int i = 0; i < 16; i++) p.default_scope_key[i] = (uint8_t)(i + 1); + p.ble_disabled = 1; p.display_brightness = 50; p.wake_on_msg = 0; p.screen_off_secs = 60; + p.auto_shutdown_mv = 3300; p.v_contact_enabled = 0; p.v_battery_alert_mv = 3500; + p.v_contact_flags = 0x81; p.wifi_enabled = 0; + strcpy(p.wifi_ssid, "MyNet"); strcpy(p.wifi_pwd, "secret"); + return p; +} + +std::string companionBytes(const NodePrefs &p) { + uint8_t b[COMPANION_PREFS_SIZE]; + companionPrefsEncode(p, b, sizeof(b)); + return std::string((const char *)b, sizeof(b)); +} +std::string serverBytes(const NodePrefs &p) { + uint8_t b[SERVER_PREFS_SIZE]; + serverPrefsEncode(p, b, sizeof(b)); + return std::string((const char *)b, sizeof(b)); +} + +} // namespace + +TEST(pjson_companion_roundtrip, "UNIT-PJSON-001", "Companion prefs survive prefs.json field for field") { + NodePrefs p = sample(); + MemStream s; + CHECK(companionPrefsToJson(p, s)); + NodePrefs q = defaults(); + CHECK(companionPrefsFromJson(q, s)); + // Every field of the binary layout, as a legacy file migrated to JSON. + CHECK(companionBytes(q) == companionBytes(p)); +} + +TEST(pjson_server_roundtrip, "UNIT-PJSON-002", "Server prefs survive prefs.json field for field") { + NodePrefs p = sample(); + MemStream s; + CHECK(serverPrefsToJson(p, s)); + NodePrefs q = defaults(); + CHECK(serverPrefsFromJson(q, s)); + CHECK(serverBytes(q) == serverBytes(p)); +} + +TEST(pjson_upstream_file, "UNIT-PJSON-003", "A companion prefs.json as upstream v1.17 writes it loads") { + MemStream s("{name:\"Up\",lat:1.5,lon:2.5,radio:{freq:869.6180,bw:62.5000,sf:8,cr:5,cad:1," + "int_thr:0,rxgain:1,fem_rxgain:0,fem_txgain:0,tx:20,af:9.0000,rxdelay:0," + "f_txdelay:0.5000,d_txdelay:0.3000,agc_int:0,hash_mode:1,multi_ack:0}," + "gps:{en:0,int:300,adv_loc:0},repeat:{disable:0},comp:{auto_max:3,defs_nm:\"\"," + "defs_key:\"000102030405060708090A0B0C0D0E0F\",pin:0,buzz_q:1,vibe_q:0,auto_add:0," + "man_add:0,tel_base:0,tel_loc:0,tel_env:0,tz_offset:2},custom:{}," + "wifi:{ssid:\"x\",pwd:\"y\",enabled:1}}"); + NodePrefs q = defaults(); + CHECK(companionPrefsFromJson(q, s)); + CHECK(std::strcmp(q.node_name, "Up") == 0); + CHECK(q.sf == 8 && q.tx_power_dbm == 20 && q.freq == 869.618f && q.bw == 62.5f); + CHECK(q.client_repeat == 1); + CHECK(q.autoadd_max_hops == 3 && q.buzzer_quiet == 1 && q.tz_offset == 2); + CHECK(q.default_scope_key[15] == 0x0F); + CHECK(std::strcmp(q.wifi_ssid, "x") == 0 && std::strcmp(q.wifi_pwd, "y") == 0); + // No "zc" object: ZephCore's own fields keep their defaults. + NodePrefs d = defaults(); + CHECK(q.cad_auto == d.cad_auto && q.probe_interval == d.probe_interval && q.wake_on_msg == d.wake_on_msg); +} + +TEST(pjson_parse_errors, "UNIT-PJSON-004", "A broken prefs.json leaves prefs untouched, an empty object changes nothing") { + const char *broken[] = { "{name:\"x\"", "{radio:{sf:9}", "name:\"x\"}", "{na-me:1}" }; + for (const char *text : broken) { + NodePrefs q = sample(); + std::string before = companionBytes(q); + MemStream s(text); + if (companionPrefsFromJson(q, s)) throw std::runtime_error(std::string("accepted: ") + text); + CHECK(companionBytes(q) == before); + } + NodePrefs q = sample(); + std::string before = serverBytes(q); + MemStream s("{}"); + CHECK(serverPrefsFromJson(q, s)); + CHECK(serverBytes(q) == before); +} + +TEST(pjson_strings, "UNIT-PJSON-005", "Quotes, backslashes, newlines and UTF-8 in strings round trip") { + NodePrefs p = sample(); + strcpy(p.node_name, "A \"q\" \\b \xF0\x9F\x93\xA1"); + strcpy(p.owner_info, "line1\nline2\r\"x\""); + strcpy(p.wifi_pwd, "p\"w\\d"); + MemStream s; + CHECK(serverPrefsToJson(p, s)); + NodePrefs q = defaults(); + CHECK(serverPrefsFromJson(q, s)); + CHECK(std::strcmp(q.node_name, p.node_name) == 0); + CHECK(std::strcmp(q.owner_info, p.owner_info) == 0); + MemStream c; + CHECK(companionPrefsToJson(p, c)); + NodePrefs r = defaults(); + CHECK(companionPrefsFromJson(r, c)); + CHECK(std::strcmp(r.wifi_pwd, p.wifi_pwd) == 0); +} + +TEST(pjson_radio_floats, "UNIT-PJSON-006", "Frequencies and bandwidths survive the 4-decimal float encoding exactly") { + const float freqs[] = { 433.175f, 433.875f, 868.1f, 869.4125f, 869.525f, 869.618f, 902.125f, + 915.0f, 916.575f, 918.25f, 923.2f, 2400.0f, 2450.12f }; + const float bws[] = { 7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, 62.5f, 125.0f, 250.0f, 500.0f }; + for (float f : freqs) { + for (float bw : bws) { + NodePrefs p = sample(); + p.freq = f; p.bw = bw; + MemStream s; + CHECK(companionPrefsToJson(p, s)); + NodePrefs q = defaults(); + CHECK(companionPrefsFromJson(q, s)); + if (q.freq != f || q.bw != bw) + throw std::runtime_error("freq " + std::to_string(f) + " bw " + std::to_string(bw)); + } + } +} + +TEST(pjson_bad_radio, "UNIT-PJSON-007", "An implausible radio preset in prefs.json falls back to the defaults") { + MemStream s("{name:\"Z\",radio:{freq:5000,sf:9,tx:14},gps:{int:900}}"); + NodePrefs q = defaults(); + CHECK(serverPrefsFromJson(q, s)); + CHECK(q.freq == mesh::LoRaConfig::FREQ_MHZ); + CHECK(q.sf == mesh::LoRaConfig::SPREADING_FACTOR); + CHECK(q.tx_power_dbm == mesh::LoRaConfig::TX_POWER_DBM); + CHECK(std::strcmp(q.node_name, "Z") == 0); + CHECK(q.gps_interval == 900); +} + +TEST(pjson_server_gps_upgrade, "UNIT-PJSON-008", "Server gps.en: a file without zc.gps_set is upgraded to on once, then honoured") { + // Written before slice 10, when servers ran the GPS whatever gps.en said. + MemStream old_file("{name:\"R\",gps:{en:0,int:172800}}"); + NodePrefs q = defaults(); + CHECK(serverPrefsFromJson(q, old_file)); + CHECK(q.gps_enabled == 1 && q.gps_enabled_set == 1); + + // `gps off` after the upgrade: saved with the marker, so it holds. + q.gps_enabled = 0; + MemStream s; + CHECK(serverPrefsToJson(q, s)); + NodePrefs r = defaults(); + r.gps_enabled = 1; + CHECK(serverPrefsFromJson(r, s)); + CHECK(r.gps_enabled == 0 && r.gps_enabled_set == 1); + + // The companion has no such history: its gps.en is taken as written. + MemStream comp("{name:\"C\",gps:{en:0}}"); + NodePrefs c = defaults(); + c.gps_enabled = 1; + CHECK(companionPrefsFromJson(c, comp)); + CHECK(c.gps_enabled == 0); +} diff --git a/tests/unit/radio_tests.cpp b/tests/unit/radio_tests.cpp new file mode 100644 index 0000000..35dee15 --- /dev/null +++ b/tests/unit/radio_tests.cpp @@ -0,0 +1,200 @@ +#include "test.h" +#include +#include + +using mesh::CadController; +using mesh::NoiseFloorEstimator; + +namespace { +struct FakeCadHw : mesh::CadHw { + uint8_t base = 20, pmin = 0, pmax = 0; + int programmed = 99; + int writes = 0; + uint8_t hwCadBasePeak() override { return base; } + uint8_t hwCadPeakMin() override { return pmin; } + uint8_t hwCadPeakMax() override { return pmax; } + void hwCadSetPeakOffset(int8_t offset) override { programmed = offset; ++writes; } +}; +// Books `probes` probes at `level`, `busy` of them busy, `fp` of those false positives. +void feed(CadController& c, int8_t level, int probes, int busy, int fp) { + for (int i = 0; i < probes; ++i) c.recordProbe(level, i < busy); + for (int i = 0; i < fp; ++i) { c.setPending(level, 0); c.resolvePending(2); } +} +} // namespace + +TEST(noise_seed, "UNIT-NOISE-001", "Noise floor seeds from the first sample within the clamp window") { + NoiseFloorEstimator e; + CHECK(!e.seeded() && e.floor() == DEFAULT_NOISE_FLOOR && e.isQuiet(-20)); + CHECK(e.update(-130, -123) == NoiseFloorEstimator::SEEDED && e.floor() == -123); + NoiseFloorEstimator hot; + CHECK(hot.update(-40, -123) == NoiseFloorEstimator::SEEDED && hot.floor() == NOISE_FLOOR_CEILING_DBM); + NoiseFloorEstimator mid; + mid.update(-104, -123); + CHECK(mid.floor() == -104 && mid.tick() == 0); +} +TEST(noise_gate, "UNIT-NOISE-002", "Warm-up accepts everything then outliers are rejected except the periodic bypass") { + NoiseFloorEstimator e; + e.update(-110, -130); + const int W = 1 << NOISE_FLOOR_EMA_SHIFT, P = NOISE_FLOOR_UNGUARDED_INTERVAL; + for (int i = 0; i < W; ++i) CHECK(e.update(-60, -130) == NoiseFloorEstimator::UPDATED); + int warmed = e.floor(); + CHECK(warmed > -110); + // Ticks W..P-1: a sample SAMPLING_THRESHOLD above the floor is interference. + for (int t = W; t < P; ++t) + CHECK(e.update(warmed + NOISE_FLOOR_SAMPLING_THRESHOLD, -130) == NoiseFloorEstimator::REJECTED); + CHECK(e.floor() == warmed); + // Tick P passes unfiltered so a sustained rise is still tracked. + CHECK(e.update(warmed + NOISE_FLOOR_SAMPLING_THRESHOLD, -130) == NoiseFloorEstimator::UPDATED); + CHECK(e.floor() > warmed); + // Just below the threshold is never interference. + int f = e.floor(); + CHECK(e.update(f + NOISE_FLOOR_SAMPLING_THRESHOLD - 1, -130) == NoiseFloorEstimator::UPDATED); +} +TEST(noise_rounding, "UNIT-NOISE-003", "EMA step rounds to nearest symmetrically with a +-3 dB dead zone") { + NoiseFloorEstimator down; down.update(-100, -130); + for (int i = 0; i < 200; ++i) down.update(-110, -130); + CHECK(down.floor() == -107); + NoiseFloorEstimator up; up.update(-110, -130); + for (int i = 0; i < 7; ++i) up.update(-100, -130); // stay inside warm-up + NoiseFloorEstimator one; one.update(-100, -130); + one.update(-96, -130); CHECK(one.floor() == -99); // (+4 + 4) / 8 = +1 + one.update(-103, -130); CHECK(one.floor() == -100); // (-4 - 4) / 8 = -1 + one.update(-97, -130); CHECK(one.floor() == -100); // (+3 + 4) / 8 = 0 + CHECK(up.floor() > -110 && up.floor() <= -103); +} +TEST(noise_median_quiet, "UNIT-NOISE-004", "Median of an even burst and the quiet verdict against the floor") { + int16_t s[8] = {-90, -110, -100, -120, -95, -105, -115, -101}; + CHECK(mesh::sortAndMedian(s, 8) == -103); // (-105 + -101) / 2, truncated toward zero + for (int i = 1; i < 8; ++i) CHECK(s[i - 1] <= s[i]); + NoiseFloorEstimator e; e.update(-110, -130); + CHECK(e.isQuiet(-110 + CAD_PROBE_RSSI_GUARD) && !e.isQuiet(-110 + CAD_PROBE_RSSI_GUARD + 1)); +} + +TEST(cad_window, "UNIT-CAD-001", "Level window narrows only where the driver clamp binds") { + FakeCadHw hw; CadController c(hw); + CHECK(c.levelMinEff() == CAD_LEVEL_MIN && c.levelMaxEff() == CAD_LEVEL_MAX); + hw.pmin = 12; hw.pmax = 48; + CHECK(c.levelMinEff() == CAD_LEVEL_MIN); // 20 - 8 = 12, not below the clamp + hw.base = 18; CHECK(c.levelMinEff() == -6); // 18 - 8 = 10 < 12 + hw.base = 40; CHECK(c.levelMaxEff() == 8); // 40 + 12 = 52 > 48 + hw.base = 0; CHECK(c.levelMinEff() == CAD_LEVEL_MIN && c.levelMaxEff() == CAD_LEVEL_MAX); +} +TEST(cad_reanchor, "UNIT-CAD-002", "Configure keeps the learned peak across a base change and clamps to the window") { + FakeCadHw hw; hw.base = 44; CadController c(hw); + c.configure(true, -7, 15, 51); + CHECK(c.offset() == 0 && hw.programmed == 0 && c.autoEnabled() && c.busycapPct() == 15); + c.configure(true, -7, 15, 0); // unknown base: no guess + CHECK(c.offset() == -7); + c.configure(true, -7, 15, 44); // same base + CHECK(c.offset() == -7); + hw.pmin = 40; // min eff = -4 + c.configure(false, -7, 0, 0); + CHECK(c.offset() == -4 && hw.programmed == -4); + hw.pmin = 0; c.configure(false, 12, 0, 20); // peak 32 is offset -12 at base 44: clamped + CHECK(c.offset() == CAD_LEVEL_MIN); +} +TEST(cad_knee, "UNIT-CAD-003", "Staircase steps up below the knee, down on a clean plateau and holds at the knee") { + FakeCadHw hw; + { CadController c(hw); c.configure(true, 0, 0, 0); + feed(c, 0, 200, 60, 60); feed(c, 1, 200, 20, 20); + c.staircaseStep(); CHECK(c.offset() == 1 && hw.programmed == 1); } + { CadController c(hw); c.configure(true, 0, 0, 0); + feed(c, 0, 200, 4, 4); feed(c, -1, 200, 8, 8); + c.staircaseStep(); CHECK(c.offset() == -1 && hw.programmed == -1); } + { CadController c(hw); c.configure(true, 0, 0, 0); // steep below, flat above + feed(c, 0, 200, 4, 4); feed(c, -1, 200, 40, 40); feed(c, 1, 200, 2, 2); + c.staircaseStep(); CHECK(c.offset() == 0); } + { CadController c(hw); c.configure(true, 0, 0, 0); // flat but noisy + feed(c, 0, 200, 20, 20); feed(c, -1, 200, 22, 22); + c.staircaseStep(); CHECK(c.offset() == 0); } + { CadController c(hw); c.configure(true, 0, 0, 0); // not warm yet + feed(c, 0, CAD_STEP_MIN_PROBES - 1, 60, 60); feed(c, 1, 200, 0, 0); + c.staircaseStep(); CHECK(c.offset() == 0); } +} +TEST(cad_busycap_hysteresis, "UNIT-CAD-004", "Descent into the airtime cap is blocked by the hysteresis band") { + FakeCadHw hw; + // cap 25% -> descend only if the frontier's busy rate is <= 15%. + { CadController c(hw); c.configure(true, 0, 25, 0); + feed(c, 0, 200, 4, 4); feed(c, -1, 200, 40, 8); + c.staircaseStep(); CHECK(c.offset() == 0); } + { CadController c(hw); c.configure(true, 0, 25, 0); + feed(c, 0, 200, 4, 4); feed(c, -1, 200, 30, 8); + c.staircaseStep(); CHECK(c.offset() == -1); } + { CadController c(hw); c.configure(true, 0, 10, 0); // cap 10% still allows a 6% frontier + feed(c, 0, 200, 4, 4); feed(c, -1, 200, 12, 8); + c.staircaseStep(); CHECK(c.offset() == -1); } +} +TEST(cad_safety, "UNIT-CAD-005", "Safety rung steps up on a pathological or over-cap level regardless of auto") { + FakeCadHw hw; + { CadController c(hw); c.configure(false, 0, 0, 0); + feed(c, 0, CAD_SAFETY_MIN_PROBES - 1, CAD_SAFETY_MIN_PROBES - 1, 0); + CHECK(!c.safetyStep()); + c.recordProbe(0, true); // 20 probes, all busy + CHECK(c.safetyStep() && c.offset() == 1 && hw.programmed == 1); } + { CadController c(hw); c.configure(false, 0, 10, 0); + feed(c, 0, CAD_STEP_MIN_PROBES - 1, 13, 0); CHECK(!c.safetyStep()); + c.recordProbe(0, true); CHECK(c.safetyStep() && c.offset() == 1); } + { CadController c(hw); c.configure(false, 0, 0, 0); // busycap 0: marginal is left alone + feed(c, 0, 200, 100, 0); CHECK(!c.safetyStep()); } + { CadController c(hw); c.configure(false, CAD_LEVEL_MAX, 0, 0); + feed(c, CAD_LEVEL_MAX, 50, 50, 0); CHECK(!c.safetyStep()); } + { CadController c(hw); c.configure(false, 0, 0, 0); // adapt(): safety only when auto is off + feed(c, 0, 20, 20, 0); feed(c, 1, 200, 0, 0); + c.adapt(); CHECK(c.offset() == 1); } +} +TEST(cad_visit_relax, "UNIT-CAD-006", "Visits use their own offset and TX starvation relaxes the offset in force") { + FakeCadHw hw; CadController c(hw); + c.configure(true, 2, 0, 0); + c.beginVisit(true); + CHECK(c.visiting() && c.effectiveOffset() == 0 && hw.programmed == 0); + CHECK(c.relaxOnTxStarvation() && c.effectiveOffset() == 1 && hw.programmed == 1 && c.offset() == 2); + c.configure(true, 3, 0, 0); // set cad.* during a visit + CHECK(c.offset() == 3 && hw.programmed == 1); + c.endVisit(); + CHECK(!c.visiting() && hw.programmed == 3); + c.beginVisit(false); + CHECK(c.effectiveOffset() == 3); + c.endVisit(); + CHECK(c.relaxOnTxStarvation() && c.offset() == 4 && hw.programmed == 4); + c.configure(false, CAD_LEVEL_MAX, 0, 0); + CHECK(!c.relaxOnTxStarvation() && c.offset() == CAD_LEVEL_MAX); +} +TEST(cad_probe_levels, "UNIT-CAD-007", "Probe levels sweep when manual and mix operating and neighbours when auto") { + FakeCadHw hw; + { CadController c(hw); c.configure(false, 0, 0, 0); + int seen[CAD_SWEEP_MAX - CAD_SWEEP_MIN + 1] = {}; + for (int i = 0; i < CAD_SWEEP_MAX - CAD_SWEEP_MIN + 1; ++i) { + int l = c.pickProbeLevel(); CHECK(l >= CAD_SWEEP_MIN && l <= CAD_SWEEP_MAX); ++seen[l - CAD_SWEEP_MIN]; + } + for (int n : seen) CHECK(n == 1); } + { CadController c(hw); c.configure(false, -8, 0, 0); + for (int i = 0; i < 5; ++i) CHECK(c.pickProbeLevel() == -8); } + { CadController c(hw); c.configure(true, 0, 0, 0); + int expected[] = {-1, 0, 1, 0, -1, 0, 1, 0}; + for (int l : expected) CHECK(c.pickProbeLevel() == l); } + { hw.pmin = 20; CadController c(hw); c.configure(true, 0, 0, 0); // min eff 0: no frontier + int expected[] = {0, 0, 1, 0}; + for (int l : expected) CHECK(c.pickProbeLevel() == l); + hw.pmin = 0; } +} +TEST(cad_pending, "UNIT-CAD-008", "Pending verdicts book tp or fp, unresolved ones are withdrawn and reset drops them") { + FakeCadHw hw; CadController c(hw); + CHECK(!c.hasPending()); + c.recordProbe(0, true); c.setPending(0, 500); + CHECK(c.hasPending() && c.pendingLevel() == 0 && c.pendingDeadline() == 500); + c.resolvePending(1); + CHECK(!c.hasPending() && c.stats(0).tp == 1 && c.stats(0).fp == 0); + c.recordProbe(0, true); c.setPending(0, 900); c.resolvePending(2); + CHECK(c.stats(0).fp == 1 && c.stats(0).probes == 2 && c.stats(0).busy == 2); + c.recordProbe(0, true); c.setPending(0, 900); c.resolvePending(0); + CHECK(c.stats(0).probes == 2 && c.stats(0).busy == 2); + c.recordProbe(0, true); c.setPending(0, 900); + c.resetStats(); + CHECK(!c.hasPending() && c.pendingDeadline() == 0 && c.stats(0).probes == 0); + for (int i = 0; i < 0xFFF0; ++i) c.recordProbe(3, false); + CHECK(c.stats(3).probes == 0xFFF0); + c.recordProbe(3, true); + CHECK(c.stats(3).probes == 0x7FF9 && c.stats(3).busy == 1); + c.decayStats(); + CHECK(c.stats(3).probes == 0x3FFC && c.stats(3).busy == 0); +} diff --git a/tests/unit/telemetry_text_tests.cpp b/tests/unit/telemetry_text_tests.cpp new file mode 100644 index 0000000..a699db3 --- /dev/null +++ b/tests/unit/telemetry_text_tests.cpp @@ -0,0 +1,45 @@ +// helpers/ui/telemetry_text.cpp: the joystick UI's rendering of a repeater's telemetry. +#include "test.h" +#include "CayenneLPP.h" +#include "ui/telemetry_text.h" + +#include + +namespace { +std::string text(CayenneLPP &l) { + char out[256]; + telemetry_text(l.getBuffer(), l.getSize(), out, sizeof(out)); + return out; +} +} + +TEST(ttext_server_order, "UNIT-TTEXT-001", "A repeater's telemetry in slice-10 order renders every field, GPS second") { + CayenneLPP l(180); + l.addVoltage(1, 4.22f); + l.addGPS(1, 47.1234f, -19.5f, 91.7f); + l.addLuminosity(1, 3); + l.addTemperature(2, -2.5f); + l.addRelativeHumidity(2, 45.5f); + l.addBarometricPressure(2, 1013.2f); + l.addAltitude(2, 120.0f); + l.addTemperature(1, 23.3f); + CHECK(text(l) == "batt 4.22V\ngps 47.1234,-19.5000\nlight 3\nc2 temp -2.5C\n" + "c2 hum 45.5%\nc2 pres 1013.2hPa\nc2 alt 120m\ntemp 23.3C"); +} + +TEST(ttext_power, "UNIT-TTEXT-002", "Power monitor: channel voltage is not 'batt', current is signed") { + CayenneLPP l(180); + l.addVoltage(3, 12.10f); + l.addCurrent(3, -0.0125f); + l.addPower(3, 2.0f); + CHECK(text(l) == "c3 12.10V\nc3 cur -13mA\nc3 pwr 2W"); +} + +TEST(ttext_unknown, "UNIT-TTEXT-003", "A type the screen does not show is skipped, not the end; empty is 'no telemetry'") { + CayenneLPP l(180); + l.addGenericSensor(4, 123456.0f); + l.addVoltage(1, 3.70f); + CHECK(text(l) == "batt 3.70V"); + CayenneLPP e(10); + CHECK(text(e) == "no telemetry"); +} diff --git a/tests/unit/test.h b/tests/unit/test.h new file mode 100644 index 0000000..9c2a900 --- /dev/null +++ b/tests/unit/test.h @@ -0,0 +1,18 @@ +#pragma once +#include +#include +#include +#include + +struct TestCase { const char *id; const char *name; void (*run)(); }; +inline std::vector& testCases() { static std::vector cases; return cases; } +struct RegisterTest { + RegisterTest(const char *id, const char *name, void (*run)()) { testCases().push_back({id, name, run}); } +}; +#define TEST(symbol, id, name) static void symbol(); \ + static RegisterTest registration_##symbol(id, name, symbol); static void symbol() +#define CHECK(expression) do { if (!(expression)) \ + throw std::runtime_error(std::string(__FILE__) + ":" + std::to_string(__LINE__) + ": " #expression); } while (0) + +// Specified generator: reproducible across C++ standard library implementations. +inline uint32_t nextRandom(uint32_t& state) { state = state * 1664525u + 1013904223u; return state; } diff --git a/tests/unit/timesync_tests.cpp b/tests/unit/timesync_tests.cpp new file mode 100644 index 0000000..b8db61e --- /dev/null +++ b/tests/unit/timesync_tests.cpp @@ -0,0 +1,207 @@ +#include "test.h" +#include +#include +#include +#include +#include +#include + +namespace { +uint32_t uptime; +std::vector saved; +std::vector reports; +constexpr uint32_t epoch = 1000000; +struct Clock : mesh::RTCClock { + uint32_t now = epoch + 100; + unsigned sets = 0; + uint32_t getCurrentTime() override { return now; } + // As ZephyrRTCClock: every set also goes to the hardware RTC. + void setCurrentTime(uint32_t value) override { now = value; ++sets; saved.push_back(value); } +}; +std::array key(unsigned id) { + std::array result{}; + result[0] = id & 255; result[1] = id >> 8; return result; +} +struct Fixture { + MeshTimeSync sync; + Clock clock; + Fixture(uint32_t build = 0, bool forward = false) : sync(build, forward) { + uptime = 100; saved.clear(); reports.clear(); + CHECK(!sync.runTick(clock)); // arm first evaluation at uptime 1000 + } + void advert(unsigned id, int32_t skew, uint32_t up, uint8_t hops = 0) { + auto pub = key(id); + sync.onAdvertHeard(pub.data(), uint32_t(int64_t(epoch) + up + skew), hops, up); + } + void group(unsigned count, int32_t skew, uint32_t first = 100, uint32_t second = 3700, unsigned idBase = 1) { + for (unsigned i = 0; i < count; ++i) { + advert(idBase + i, skew, first); advert(idBase + i, skew, second); + } + } + bool evaluate(uint32_t up) { + uptime = up; clock.now = epoch + up; return sync.runTick(clock); + } + std::string status(uint32_t up, bool enabled = true) { + char out[4096]; int n = sync.formatStatus(out, sizeof(out), epoch + up, up, enabled); + CHECK(n >= 0 && size_t(n) == std::strlen(out)); return out; + } +}; +} +extern "C" int64_t k_uptime_get() { return int64_t(uptime) * 1000; } +extern "C" void time_sync_report(time_sync_source source) { reports.push_back(source); } + +TEST(timesync_pacing, "UNIT-SYNC-001", "Evaluation pacing and empty evidence leave clock untouched") { + Fixture f; + CHECK(f.sync.msUntilNextEval(100) == 900000); + CHECK(!f.evaluate(999)); CHECK(f.sync.msUntilNextEval(999) == 1000); + CHECK(!f.evaluate(1000)); CHECK(f.sync.msUntilNextEval(1000) == 900000); + CHECK(f.status(1000).find("no-data") != std::string::npos); + CHECK(f.clock.sets == 0 && saved.empty() && reports.empty()); +} +TEST(timesync_quorum, "UNIT-SYNC-002", "Normal quorum needs six tenured senders and two adverts") { + for (unsigned count : {5u, 6u}) { + Fixture f; f.group(count, 1000); + CHECK(f.evaluate(3700) == (count == 6)); + CHECK(f.clock.sets == (count == 6 ? 1u : 0u)); + } + for (uint32_t now : {3699u, 3700u}) { + Fixture f; f.group(6, 1000, 100, 200); + CHECK(f.evaluate(now) == (now == 3700)); + } + Fixture f; + for (unsigned i = 1; i <= 6; ++i) f.advert(i, 1000, 100); + CHECK(!f.evaluate(3700)); // elapsed tenure alone does not establish consistency +} +TEST(timesync_thresholds, "UNIT-SYNC-003", "Step threshold and positive negative one-hour caps") { + for (int skew : {-5000, -600, -599, 599, 600, 5000}) { + Fixture f; f.group(6, skew); + bool expected = skew <= -600 || skew >= 600; + CHECK(f.evaluate(3700) == expected); + if (expected) { + int delta = std::max(-3600, std::min(3600, skew)); + CHECK(f.sync.lastStepDelta() == delta); + CHECK(f.clock.now == uint32_t(int64_t(epoch) + 3700 + delta)); + CHECK(saved == std::vector({f.clock.now})); + CHECK(reports == std::vector({TIME_SYNC_MESH})); + } else CHECK(saved.empty() && reports.empty()); + } +} +TEST(timesync_majority, "UNIT-SYNC-004", "Split vote abstains and strict majority defeats outliers") { + for (unsigned majority : {3u, 4u}) { + Fixture f; f.group(majority, 1000); f.group(6 - majority, -2000, 100, 3700, 20); + CHECK(f.evaluate(3700) == (majority == 4)); + if (majority == 4) CHECK(f.sync.lastStepDelta() == 1000); + else CHECK(f.status(3700).find("no-majority") != std::string::npos); + } +} +TEST(timesync_forward_only, "UNIT-SYNC-005", "Forward-only roles report but never apply backward corrections") { + Fixture f(0, true); f.group(6, -1000); + CHECK(!f.evaluate(3700)); CHECK(f.clock.sets == 0 && saved.empty() && reports.empty()); + CHECK(f.status(3700).find("skipped: forward-only") != std::string::npos); + CHECK(f.sync.lastStepDelta() == 0); +} +TEST(timesync_bootstrap, "UNIT-SYNC-006", "Bootstrap needs three fresh peers and bypasses normal step cap") { + for (unsigned count : {2u, 3u}) { + Fixture f(epoch); + for (unsigned i = 1; i <= count; ++i) f.advert(i, 0, 100); + uptime = 1000; f.clock.now = 0; + CHECK(f.sync.runTick(f.clock) == (count == 3)); + if (count == 3) CHECK(f.clock.now == epoch + 1000 - 150); + else CHECK(saved.empty()); + } + Fixture f(epoch); f.sync.noteManualSync(100); + for (unsigned i = 1; i <= 3; ++i) f.advert(i, 0, 100); + uptime = 1000; f.clock.now = 0; + CHECK(!f.sync.runTick(f.clock) && saved.empty()); +} +TEST(timesync_suppression, "UNIT-SYNC-007", "Manual and GPS suppression expire exactly at seven days") { + for (bool gps : {false, true}) for (uint32_t elapsed : {604799u, 604800u}) { + Fixture f; + if (gps) f.sync.noteGPSSync(100); else f.sync.noteManualSync(100); + uint32_t now = 100 + elapsed; + f.group(6, 700, now - 3600, now); // fresh tenured evidence at expiry + CHECK(f.evaluate(now) == (elapsed == 604800)); + if (elapsed < 604800) CHECK(f.status(now).find("suppressed") != std::string::npos); + } + Fixture f; f.sync.noteGPSSync(100); f.sync.noteGPSSync(600000); + f.group(6, 700, 601300, 604900); CHECK(!f.evaluate(604900)); +} +TEST(timesync_pedigree, "UNIT-SYNC-008", "Trusted-clock drift envelope rejects implausible consensus") { + for (int skew : {781, 782}) { // floor(7 days * 300ppm) + 600 = 781 seconds + Fixture f; f.sync.noteManualSync(100); f.group(6, skew, 601300, 604900); + CHECK(f.evaluate(604900) == (skew == 781)); + if (skew == 782) CHECK(f.status(604900).find("pedigree-veto") != std::string::npos); + } +} +TEST(timesync_rate_limit, "UNIT-SYNC-009", "Clock steps remain rate-limited for six hours") { + for (uint32_t elapsed : {21599u, 21600u}) { + Fixture f; f.group(6, 5000); CHECK(f.evaluate(3700)); // cap leaves 1400s to correct + uint32_t now = 3700 + elapsed; + uptime = now; f.clock.now = epoch + now + 3600; + CHECK(f.sync.runTick(f.clock) == (elapsed == 21600)); + CHECK(f.clock.sets == (elapsed == 21600 ? 2u : 1u)); + if (elapsed == 21600) CHECK(f.sync.lastStepDelta() == 1400); + } +} +TEST(timesync_admission, "UNIT-SYNC-010", "Hop limit monotonic dedup and eight-byte sender identity") { + Fixture f; auto pub = key(1); + f.advert(1, 1000, 100, 4); CHECK(f.sync.wouldAccept(pub.data(), epoch + 1100)); + f.advert(1, 1000, 100, 3); CHECK(!f.sync.wouldAccept(pub.data(), epoch + 1100)); + CHECK(!f.sync.wouldAccept(pub.data(), epoch + 1099)); + CHECK(f.sync.wouldAccept(pub.data(), epoch + 1101)); + auto separate = pub; separate[7] = 1; + CHECK(f.sync.wouldAccept(separate.data(), epoch + 1100)); + separate = pub; separate[8] = 1; + CHECK(!f.sync.wouldAccept(separate.data(), epoch + 1100)); // table key is exactly 8 bytes + f.group(5, 1000, 100, 3700, 2); + f.advert(1, 1000, 100, 3); // duplicate cannot earn the second advert + CHECK(!f.evaluate(3700)); +} +TEST(timesync_stale_inconsistent, "UNIT-SYNC-011", "Stale samples expire and inconsistent sender must re-earn tenure") { + for (uint32_t age : {432000u, 432001u}) { + Fixture f; f.group(6, 1000); CHECK(f.evaluate(3700 + age) == (age == 432000)); + } + Fixture f; f.group(6, 1000); + f.advert(1, 2000, 3800); // timestamp jumped an extra 1000 seconds + CHECK(!f.evaluate(3800)); + CHECK(f.status(3800).find("no-quorum") != std::string::npos); + f.advert(1, 1000, 7500); // inconsistent again: still cannot vote + CHECK(!f.evaluate(7500)); +} +TEST(timesync_eviction, "UNIT-SYNC-012", "Full evidence table protects mature and equally-close senders") { + Fixture f; + for (unsigned i = 1; i <= 32; ++i) f.advert(i, 0, 100, 3); + auto stranger = key(99); + f.advert(99, 0, 101, 3); CHECK(f.sync.wouldAccept(stranger.data(), epoch + 101)); + f.advert(99, 0, 102, 0); CHECK(!f.sync.wouldAccept(stranger.data(), epoch + 102)); + auto evicted = key(1); CHECK(f.sync.wouldAccept(evicted.data(), epoch + 100)); + Fixture mature; + for (unsigned i = 1; i <= 32; ++i) { + mature.advert(i, 0, 100, 3); mature.advert(i, 0, 3700, 3); + } + mature.advert(99, 0, 3701, 0); CHECK(mature.sync.wouldAccept(stranger.data(), epoch + 3701)); + mature.advert(99, 0, 3700 + 86401, 0); + CHECK(!mature.sync.wouldAccept(stranger.data(), epoch + 3700 + 86401)); +} +TEST(timesync_status_bounds, "UNIT-SYNC-013", "Status formatting respects every buffer capacity including zero") { + Fixture f; f.group(6, 1000); + for (size_t capacity = 0; capacity <= 256; ++capacity) { + std::vector out(capacity + 2, '#'); + int written = f.sync.formatStatus(out.data() + 1, capacity, epoch + 3700, 3700, true); + CHECK(out.front() == '#' && out.back() == '#'); + CHECK(written >= 0 && size_t(written) <= (capacity ? capacity - 1 : 0)); + if (capacity) CHECK(std::strlen(out.data() + 1) == size_t(written)); + } + CHECK(f.status(3700, false).find("off (dry-run)") == 0); + CHECK(f.clock.sets == 0 && saved.empty()); +} +TEST(timesync_reset, "UNIT-SYNC-014", "Reset discards evidence suppression counters and scheduling state") { + Fixture f; f.group(6, 1000); CHECK(f.evaluate(3700)); f.sync.noteManualSync(3700); + f.sync.reset(0, false); + CHECK(f.sync.lastStepDelta() == 0 && f.sync.msUntilNextEval(3700) == 0); + auto pub = key(1); CHECK(f.sync.wouldAccept(pub.data(), epoch + 1100)); + CHECK(f.status(3700).find("no-data") != std::string::npos); + CHECK(f.status(3700).find("evals=0") != std::string::npos); + f.group(6, 1000, 3800, 7400); CHECK(!f.evaluate(7400)); // re-arm pacing + CHECK(f.evaluate(8300)); // old suppression is gone +}