Testing
The suite is a seed, not coverage. It’s enough structure that adding a test is dropping one
.c file into tests/ and adding one line, plus a first pass of assertions over each
layer. Nothing here exercises the five nodes’ logic — §3 is what does, and
§4 is what neither reaches.
cmake -B build && cmake --build build -j
ctest --test-dir build --output-on-failure -LE integration # unit only, ~2 s
ctest --test-dir build --output-on-failure # + the platform smoke testThe harness is Unity, fetched at configure time exactly
the way the FreeRTOS kernel is, with the same escape hatch for a machine that cannot reach GitHub
(-DMINSCS_UNITY_PATH=/path/to/Unity for a local checkout, -DMINSCS_BUILD_TESTS=OFF to skip both).
Each test file writes its own main with an explicit RUN_TEST list, which is Unity’s no-Ruby
path — no generated runners, nothing to regenerate. Unity’s double assertions are compiled in
(UNITY_INCLUDE_DOUBLE), because the service-20 parameter codec and the config loader both work in
double and a float comparison would check a narrowed copy of the value rather than the value.
1. What the suite does not need
No FreeRTOS kernel, no sockets, no ports. That every unit test builds and runs without the scheduler is a property of how the platform is laid out, not a scoping choice:
- The wire format is
static inlinein headers that are already FreeRTOS-free so the flight code and the ground tools decode identical bytes —pus.h,shared_can.h,rmap.h,spacewire.h,datapool.handevents.h. A test just includes them. common/config.cis plain libc, because it runs inmain()before the scheduler starts.- All of
sim/references exactly one symbol from outside itself,logPrintfLevel, at two call sites.tests/test_stubs.csupplies a no-op version of it plus the two interface images (xAocsIcd,xEpsIcd, defined in the nodes rather than insim/, since neither module owns the interface) — and with that, ~2400 lines of physics link into a host binary.
The five nodes have no such seam: they’re FreeRTOS applications whose logic runs in tasks and reaches the outside world through UDP and TCP. There’s no honest way to unit-test them, so the smoke test runs them instead.
2. The unit tests
| File | Covers | Notable assertions |
|---|---|---|
test_pus.c | include/pus.h | CRC-16-CCITT against the published check value; TM and TC encode→decode round-trips; pusFrame’s three return contracts, and that it deliberately ignores the CRC where pusExtract checks it; the parameter value codec per type; APID↔CAN-address provenance |
test_can.c | include/shared_can.h | CAN_ID packing swept over the whole 11-bit space; a byte-exact 13-octet wire vector; both canDeserialize rejection paths; that a lower function code yields a lower identifier, which is what wins arbitration |
test_spw.c | spacewire.h, rmap.h | RMAP’s CRC-8 against an independent implementation of the same standard; header-CRC and data-CRC corruption producing the two different status codes; reads and reply-addresses answered “not implemented” |
test_icd.c | aocs_icd.h, eps_icd.h | _Static_asserts that each guarded span ends exactly where its RW segment begins — these fail the build, not the run |
test_version.c | include/version.h | That MINSCS_VERSION_STR says the same thing as the three numeric macros, which nothing else checks |
test_datapool.c | include/datapool.h | Every catalog row round-trips through its mnemonic; no identifier is used twice; the 0x80 band rule holds for all 194 rows; every ROM default lies inside its own limits |
test_events.c | include/events.h | Every event renders as its own name and no identifier is used twice — the X-macro’s whole purpose, since the DHS raises these and egse_tm names them from the one table; that zero is not an event; that an identifier a newer spacecraft sends renders ? rather than failing |
test_config.c | common/config.c | Sections scoping keys; case-insensitive lookup; a missing file still leaving a valid cfg_t; that a 69-character TLE line fits CFG_VALUE_LEN and a longer value truncates silently rather than failing |
test_sim.c | sim/ | Quaternion invariants and one pinned sign convention; seeded noise reproducibility; that a sub-second step advances the clock (the integer-millisecond rule); that a bad element set degrades to a circular fallback instead of failing; and both interface guards catching a write inside the span while permitting one to RW |
Two of those rows carry a rule rather than an assertion. The band-rule test iterates the whole
catalog rather than sampling it, because “a low octet below 0x80 is a measurement” is only worth
having if an operator can apply it to any identifier on sight; if it ever fails, what’s owed is
an OBDB_VERSION bump, never a relaxed assertion. The guard tests require both halves — a poke
inside the guarded span must fail the check and a poke to RW must pass it
(dyn_engine.md §9).
Adding one
Drop a .c file in tests/ with its own main, and add one line to
tests/CMakeLists.txt:
#include "unity.h"
void setUp(void) {} /* Unity requires both, even empty */
void tearDown(void) {}
static void test_something(void) { TEST_ASSERT_EQUAL_HEX16(0x29B1, thing()); }
int main(void) { UNITY_BEGIN(); RUN_TEST(test_something); return UNITY_END(); }minscs_add_test(test_something) # header-only
minscs_add_test(test_something ../common/thing.c) # with extra sourcesminscs_add_test mirrors add_freertos_node’s ${ARGN} idiom one directory up: sources after the
name are compiled into that test alone. It links unity and nothing else — deliberately not
freertos_kernel.
Two implementations of one specification agreeing is a real check; a constant read back out of
the implementation under test is not. That’s why pusCrc16 is checked against the published
CRC-16/CCITT-FALSE value for "123456789" and rmapCrc8 against a second, differently-written
implementation of the same ECSS algorithm.
3. The smoke test
tests/smoke_sim.sh is the half of the suite the unit tests structurally
can’t reach. It brings the platform up with run_sim.sh — which already exits
non-zero the moment any child dies, the one pass/fail signal the repo had before this — and adds
assertions:
- all seven processes still running after the settling time;
- telemetry decoded from all five subsystem APIDs;
- a
DHS PINGanswered with a successful completion report, TM[1,7]; - still running at the end.
Assertion 2 is the strong one. A packet under APID 3 means the ADCS produced a CAN report, the DHS
ingested it into the platform mirror, built a PUS packet under the producing subsystem’s APID,
segmented it over CAN_FUNC_PKT to TTC, and TTC streamed it to the EGSE — the whole chain, end
to end, for one subsystem. Five of them means the bus is routing.
It’s labelled integration, so ctest -LE integration is the fast run and ctest -L integration
is this alone. It’s RUN_SERIAL and skips (exit 77) rather than failing when TCP 21000 or
21001 is already bound: every address in the platform is a compile-time #define in
shared_can.h and every server binds INADDR_LOOPBACK, so a second
copy of the simulator cannot be relocated onto other ports — only detected.
Timings are environment-overridable, since every housekeeping cadence in
config/minscs.conf is 5 s and a slow machine may want more:
MINSCS_SMOKE_SETTLE_S=25 MINSCS_SMOKE_WATCH_S=20 tests/smoke_sim.sh.
What assertion 2 caught on its first run
The first run reported four APIDs where there should have been five: TTC telemetry was never
reaching the ground at all, and had not been. prvLinkTask
(PRIO_APP) looped between a bounded recv() and send() without ever yielding, so
prvHkTask (PRIO_TLM, strictly lower) was never scheduled once —
the first hazard in
porting.md,
in the one node that didn’t fully defend against it, fixed with the vTaskDelay(1) that rule
already prescribes. Nothing errored and nothing logged.
No unit test could have seen it: the failure was in the interaction between two task priorities and a socket, which only shows up when the whole platform runs. The lesson for this file: do not weaken an assertion to match a broken platform. Asserting four subsystems instead of five would have been a one-word edit, would have gone green, and would have left the bug in place indefinitely.
4. What is not covered
Honest list, so nobody reads a green run as more than it is:
- The nodes’ logic. Telecommand routing, the verification state machine, SSMM retention, the
schedule, mode transitions, FDIR, redundancy switching, the on-board database’s three-layer
composition — no decision is unit-tested. The smoke test proves the chain carries packets, not
that any decision inside it is right, and
test_events.cchecks that the FDIR events render and don’t collide, not that anything raises them. - The control laws. Whether B-dot actually detumbles is a question about a trajectory, not a function call; adcs.md describes watching it instead.
- The plants’ physics.
test_sim.casserts orders of magnitude and invariants, never values. The fidelity envelopes are in dyn_engine.md. - The XTCE mission database.
mdb/minscs.xtceis a second, independent description of the same wire format and nothing checks it against the C. Keeping them in step is still a manual step in every change — see mdb.md. - Docker and the containers. Not built or run by CI.
5. Continuous integration
.github/workflows/ci.yml configures, builds the whole tree and
runs both passes on push and pull request. Before it existed nothing in CI ever compiled the C:
docs.yml is path-filtered to docs/**. The two ctest
invocations are separate steps on purpose — a red integration run should not be mistakable for a
broken packet layer.