Skip to content

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 test

The 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 inline in 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.h and events.h. A test just includes them.
  • common/config.c is plain libc, because it runs in main() before the scheduler starts.
  • All of sim/ references exactly one symbol from outside itself, logPrintfLevel, at two call sites. tests/test_stubs.c supplies a no-op version of it plus the two interface images (xAocsIcd, xEpsIcd, defined in the nodes rather than in sim/, 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

FileCoversNotable assertions
test_pus.cinclude/pus.hCRC-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.cinclude/shared_can.hCAN_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.cspacewire.h, rmap.hRMAP’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.caocs_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.cinclude/version.hThat MINSCS_VERSION_STR says the same thing as the three numeric macros, which nothing else checks
test_datapool.cinclude/datapool.hEvery 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.cinclude/events.hEvery 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.ccommon/config.cSections 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.csim/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 sources

minscs_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:

  1. all seven processes still running after the settling time;
  2. telemetry decoded from all five subsystem APIDs;
  3. a DHS PING answered with a successful completion report, TM[1,7];
  4. 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.c checks 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.c asserts orders of magnitude and invariants, never values. The fidelity envelopes are in dyn_engine.md.
  • The XTCE mission database. mdb/minscs.xtce is 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.

Last updated on