mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-10-22 20:11:33 +00:00
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:
1 parent
059022403b
commit
159c086485
33 files changed
+3830
-137
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
@@ -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(); }
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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)
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
#ifndef IS_ENABLED
|
||||
#define IS_ENABLED(value) (value)
|
||||
#endif
|
||||
#define ARG_UNUSED(value) ((void)(value))
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user