test: add firmware host harness header — TEST() macro + assert macros
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run

Child 1 of the bf-56v harness split (grandparent bf-4ne). Establishes
the public header API (firmware/test/test_runner.h) that every test unit
and the runner compile against:

- TEST(name): declares a void body plus a GCC constructor
  (__attribute__((constructor))) that self-registers it via
  test_register(name, fn) before main() runs — so a new test_*.c is
  picked up with zero SOURCES edits.
- ASSERT_EQ / ASSERT_TRUE / ASSERT_FALSE: on mismatch, call
  test_record_failure(file, line, fmt...) which longjmp()s back to the
  per-test setjmp (the live jmp target lives in test_runner.c, a later
  child) — aborting only the current test, not the whole suite.
- Prototypes for test_register(const char*, test_fn) and
  test_record_failure(...).

Header comment records the bf-21t decision (gcc host harness, not
ESP-IDF --target linux: csi.c/provision.c are blocked by esp_wifi.h /
driver/uart.h, which have no linux build, and main is one component)
and names the single run command ('make -C firmware/test test') that a
later child makes real.

Self-contained: only libc (setjmp.h, stdbool.h, stdint.h); no
firmware/main/* includes; no main(). A trivial includer using TEST(x){}
compiles cleanly with 'gcc -std=c11 -Wall -Wextra -c'.

test_runner.c, the Makefile, and the sanity test belong to sibling
beads and are intentionally not part of this commit.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-07-03 06:57:58 -04:00
parent 9a3305bd23
commit 128024c415

132
firmware/test/test_runner.h Normal file
View file

@ -0,0 +1,132 @@
/*
* ============================================================================
* Spaxel firmware host test harness gcc runner
* ============================================================================
*
* SINGLE COMMAND TO COMPILE AND RUN (from the repo root):
*
* make -C firmware/test test
*
* That compiles every test_*.c in firmware/test/ with plain gcc and runs the
* whole suite. The runner exits non-zero if ANY assertion fails, so it is safe
* to wire into CI (make's exit code reflects the test result).
*
* ----------------------------------------------------------------------------
* WHY A PLAIN gcc HARNESS (and not ESP-IDF --target linux / Unity-host)
* ----------------------------------------------------------------------------
* the firmware/main C sources cannot be compiled or linked for the ESP-IDF `linux`
* target, so the idf.py host-test / Unity path was rejected:
*
* - csi.c pulls in esp_wifi.h CSI callback + promiscuous-mode
* API backed by the radio MAC/baseband
* blob. No host build exists.
* - provision.c pulls in driver/uart.h UART peripheral driver. No host
* build exists.
*
* Worse, firmware/main builds as ONE idf_component_register(...) whose
* REQUIRES line names esp_wifi, bt, and driver three components with no
* linux build so the whole component (and thus every translation unit in
* it) is unhostable, even nvs_migration.c, whose own includes would otherwise
* be hostable in isolation. The IDF host-test model builds whole components,
* not cherry-picked TUs, so there is no way to host-compile csi.c/wifi.c/
* websocket.c/ble.c/provision.c/led.c/ntp.c without refactoring production
* firmware purely to satisfy tests.
*
* This harness therefore does NOT #include or link the firmware/main C sources. It tests
* pure-logic extractions and binary-format contracts in self-contained units
* that compile with nothing more than a C compiler. The esp_wifi/uart/nvs call
* sites themselves remain validated on-target and via the Go-side spaxel-sim
* acceptance suite.
*
* Full decision record: docs/notes/firmware-host-test-approach.md (bead
* bf-21t). This harness is the bf-4ne build-out.
*
* ----------------------------------------------------------------------------
* ADDING A TEST
* ----------------------------------------------------------------------------
* Drop a new file firmware/test/test_<thing>.c and write:
*
* #include "test_runner.h"
*
* TEST(my_thing) {
* ASSERT_EQ(1 + 1, 2);
* }
*
* The Makefile globs test_*.c, so the new file is compiled and its TEST() is
* self-registered via a GCC constructor no change to the runner required.
* ============================================================================
*/
#pragma once
#include <setjmp.h>
#include <stdbool.h>
#include <stdint.h>
/* ---- Per-test self-registration ----------------------------------------- */
typedef void (*test_fn)(void);
typedef struct {
const char *name;
test_fn fn;
} test_entry_t;
/*
* Append a test to the global registry. Called from constructor functions
* emitted by the TEST() macro, so the list is fully populated before main().
*/
void test_register(const char *name, test_fn fn);
/*
* Define a self-registering test. Expands to a static body function plus a
* constructor that registers it.
*
* TEST(addition) { ASSERT_EQ(1 + 1, 2); }
*/
#define TEST(name) \
static void spaxel_test_##name##_body(void); \
static void spaxel_test_##name##_reg(void) __attribute__((constructor)); \
static void spaxel_test_##name##_reg(void) { \
test_register(#name, spaxel_test_##name##_body); \
} \
static void spaxel_test_##name##_body(void)
/* ---- Assertions --------------------------------------------------------- */
/*
* On failure these record the location, mark the suite failed, and longjmp out
* of the CURRENT test only the remaining tests still run. main() returns
* non-zero if any assertion has failed across the whole run.
*/
void test_record_failure(const char *file, int line, const char *fmt, ...);
#define ASSERT_TRUE(cond) \
do { \
if (!(cond)) { \
test_record_failure(__FILE__, __LINE__, \
"ASSERT_TRUE(%s) failed", #cond); \
} \
} while (0)
#define ASSERT_FALSE(cond) \
do { \
if ((cond)) { \
test_record_failure(__FILE__, __LINE__, \
"ASSERT_FALSE(%s) failed", #cond); \
} \
} while (0)
/*
* Integer-equality assertion (actual/expected are evaluated once each and
* compared as long). Use ASSERT_TRUE for floats, strings, or pointers.
*/
#define ASSERT_EQ(actual, expected) \
do { \
long _spaxel_a = (long)(actual); \
long _spaxel_e = (long)(expected); \
if (_spaxel_a != _spaxel_e) { \
test_record_failure(__FILE__, __LINE__, \
"ASSERT_EQ(%s, %s) failed: got %ld, want %ld", \
#actual, #expected, _spaxel_a, _spaxel_e); \
} \
} while (0)