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)
This commit is contained in:
liquidraver committed 2026-09-25 21:56:48 +02:00
1 parent 059022403b
commit 159c086485
33 files changed
+3830 -137

No files matched your search

+32
View File
@@ -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
+55 -137
View File
@@ -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_<version>-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
+732
View File
@@ -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.
+60
View File
@@ -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()
+158
View File
@@ -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.
+526
View File
@@ -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"
}
]
}
+78
View File
@@ -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()
+264
View File
@@ -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())
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include "test.h"
#include <mesh/Dispatcher.h>
#include <mesh/StaticPoolPacketManager.h>
#include <algorithm>
#include <deque>
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<std::vector<uint8_t>> incoming;
std::vector<std::vector<uint8_t>> 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<mesh::Packet> received;
std::vector<mesh::Packet*> held;
std::vector<uint32_t> wakes;
std::vector<int8_t> 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<Dispatcher*>(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(); }
};
}
+18
View File
@@ -0,0 +1,18 @@
#include <mesh/Utils.h>
#include "hash_capture.h"
#include <stdexcept>
namespace test_support {
std::vector<uint8_t> 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);
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
namespace test_support {
extern std::vector<uint8_t> hash_input;
extern size_t hash_output_size;
}
+41
View File
@@ -0,0 +1,41 @@
#include <mesh/Utils.h>
#include <stdexcept>
#include <cstring>
// 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");
}
+4
View File
@@ -0,0 +1,4 @@
#pragma once
#include <cstdint>
// The time-sync target uses only uptime, not a simulated Zephyr scheduler.
extern "C" int64_t k_uptime_get();
+13
View File
@@ -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 <typename... Args> 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)
+4
View File
@@ -0,0 +1,4 @@
#pragma once
#include <cstdint>
// Deterministic boundary for Dispatcher default CAD retry jitter. Not entropy.
inline uint32_t sys_rand32_get() { return 17; }
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#ifndef IS_ENABLED
#define IS_ENABLED(value) (value)
#endif
#define ARG_UNUSED(value) ((void)(value))
+42
View File
@@ -0,0 +1,42 @@
#include "test.h"
#include <AdvertDataHelpers.h>
#include <UTF8Helpers.h>
#include <algorithm>
#include <cstring>
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<uint8_t> input(len, 0); if (len) input[0] = flags << 4;
AdvertDataParser parser(input.data(), len);
CHECK(parser.isValid() == (len == required));
}
}
std::vector<uint8_t> 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);
}
+48
View File
@@ -0,0 +1,48 @@
#include "test.h"
#include <mesh/ContentionTracker.h>
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);
}
+188
View File
@@ -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<uint32_t>({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<std::vector<uint8_t>>({{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<int8_t>({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<mesh::Packet*> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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);
}
+58
View File
@@ -0,0 +1,58 @@
#include "test.h"
#include <TxtDataHelpers.h>
#include <battery_curve.h>
#include <mesh/MeshCore.h>
#include <algorithm>
#include <cstring>
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<char> 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;
}
}
+85
View File
@@ -0,0 +1,85 @@
#include "test.h"
#include <mesh/Identity.h>
#include <algorithm>
#include <array>
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<uint8_t> hex(const char *text) {
std::string value(text); CHECK(value.size() % 2 == 0);
std::vector<uint8_t> 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<uint8_t>(wire, wire + 96), std::vector<uint8_t>(storage, storage + 96),
std::vector<uint8_t>(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<uint8_t, 98> 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.
}
+45
View File
@@ -0,0 +1,45 @@
#include "test.h"
#include <zephyr/drivers/lora/zc_lora_timing.h>
#include <lr11xx_cad_peak.h>
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);
}
}
+50
View File
@@ -0,0 +1,50 @@
// helpers/compat/CayenneLPP.h: upstream's wire format, rounded to nearest.
#include "test.h"
#include "CayenneLPP.h"
#include <vector>
namespace {
std::vector<uint8_t> bytes(CayenneLPP &l) {
return std::vector<uint8_t>(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<uint8_t>({1, 116, 0x01, 0x81})); // 385
l.reset();
l.addTemperature(2, 21.96f); // 220, not 219
CHECK(bytes(l) == std::vector<uint8_t>({2, 103, 0x00, 0xDC}));
l.reset();
l.addRelativeHumidity(2, 45.3f); // 90.6 -> 91
CHECK(bytes(l) == std::vector<uint8_t>({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<uint8_t>({1, 103, 0xFF, 0xE6}));
l.reset();
l.addCurrent(3, -0.0125f); // -12.5 mA -> -13
CHECK(bytes(l) == std::vector<uint8_t>({3, 117, 0xFF, 0xF3}));
l.reset();
l.addAltitude(2, -3.4f);
CHECK(bytes(l) == std::vector<uint8_t>({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<uint8_t>({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);
}
+24
View File
@@ -0,0 +1,24 @@
#include "test.h"
#include <iostream>
#include <set>
int main(int argc, char **argv)
{
std::set<std::string> 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 <exact-id>\n";
return 2;
}
+115
View File
@@ -0,0 +1,115 @@
#include "test.h"
#include "hash_capture.h"
#include <mesh/Packet.h>
#include <array>
#include <algorithm>
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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t>({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<uint8_t>({2, 0xAA, 0xBB}));
p.header = (PAYLOAD_TYPE_TRACE << 2) | ROUTE_TYPE_DIRECT; p.calculatePacketHash(hash);
CHECK(test_support::hash_input == std::vector<uint8_t>({9, 2, 0xAA, 0xBB}));
}
+49
View File
@@ -0,0 +1,49 @@
#include "test.h"
#include <RoutingPolicy.h>
#include <RateLimiter.h>
#include <mesh/Maintenance.h>
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);
}
+65
View File
@@ -0,0 +1,65 @@
#include "test.h"
#include <mesh/StaticPoolPacketManager.h>
#include <set>
using namespace mesh;
TEST(pool_capacity, "UNIT-POOL-001", "Exhaust reclaim and reuse all pool slots") {
StaticPoolPacketManager pool;
std::set<Packet*> 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<Packet*> 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<Packet*> 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);
}
+299
View File
@@ -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 <cmath>
#include <cstddef>
#include <cstring>
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<const uint8_t *>(&p) + f.struct_off;
}
uint8_t *field(NodePrefs &p, const Field &f) {
return reinterpret_cast<uint8_t *>(&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 <size_t N>
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 <size_t N>
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);
}
+193
View File
@@ -0,0 +1,193 @@
// prefs.json (helpers/PrefsJson.cpp over upstream's ConfigSerializer).
#include "test.h"
#include "PrefsJson.h"
#include "PrefsCodec.h"
#include <cstring>
#include <string>
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);
}
+200
View File
@@ -0,0 +1,200 @@
#include "test.h"
#include <CadController.h>
#include <NoiseFloorEstimator.h>
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);
}
+45
View File
@@ -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 <string>
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");
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>
struct TestCase { const char *id; const char *name; void (*run)(); };
inline std::vector<TestCase>& testCases() { static std::vector<TestCase> 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; }
+207
View File
@@ -0,0 +1,207 @@
#include "test.h"
#include <MeshTimeSync.h>
#include <helpers/time_sync.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
#include <algorithm>
#include <array>
#include <cstring>
namespace {
uint32_t uptime;
std::vector<uint32_t> saved;
std::vector<time_sync_source> 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<uint8_t, 32> key(unsigned id) {
std::array<uint8_t, 32> 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<uint32_t>({f.clock.now}));
CHECK(reports == std::vector<time_sync_source>({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<char> 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
}