Skip to content

Instantly share code, notes, and snippets.

@tehranian
Created February 17, 2026 03:46
Show Gist options
  • Select an option

  • Save tehranian/f8d193c44f3423566ae9e326a4dd59b7 to your computer and use it in GitHub Desktop.

Select an option

Save tehranian/f8d193c44f3423566ae9e326a4dd59b7 to your computer and use it in GitHub Desktop.
AqualinkD: Test Coverage Proposal

Test Coverage Proposal for AqualinkD

Analysis of testability across the AqualinkD codebase, framework recommendation, and a concrete plan for the first test PR.


1. Current State: No Safety Net

AqualinkD has 39 source files (excluding the vendored mongoose.c), 0 test files, and no make test target. Every change — bug fix, new feature, protocol tweak — ships without automated verification.

This isn't unusual for embedded-ish C projects, but AqualinkD has grown to the point where manual testing can't cover the surface area. The codebase handles external input (JSON from web clients, raw bytes from an RS-485 bus), does manual buffer management, and has protocol handlers that vary across hardware revisions. These are exactly the areas where automated tests prevent regressions.

What we're working with

source/
├── 39 .c files          (~35,000 lines, excluding mongoose.c)
├── 38 .h files
├── 0 test files
└── Makefile             (no test target)

2. Testability Assessment

Every source file falls into one of four tiers based on how much work is needed to test it.

Tier 1: Immediately testable (pure functions, no globals, no I/O)

These functions take inputs, return outputs, and touch nothing else. Tests can call them directly.

File Lines Key functions Why it's testable
rs_msg_utils.c 577 rsm_strncpy, rsm_strcmp, rsm_atoi, rsm_atof, rsm_strnstr, rsm_strncasestr, rsm_HHMM2min, rsm_get_revision, rsm_get_boardcpu All pure string/message utilities. No includes of aqualink.h, no globals.
timespec_subtract.c 46 timespec_subtract Single pure function. Computes time difference with carry handling.
debug_timer.c 75 Timer start/stop/report functions Pure timing utilities (when clock_gettime is available).
utils.c (subset) ~200 cleanwhitespace, trimwhitespace, stripwhitespace, chopwhitespace, cleanint, text2bool, request2bool, bool2text, degFtoC, degCtoF, count_characters, stristr, prittyString, getTemperatureUOM, isUomTemperature, timespec2float, text2elevel, elevel2text These are self-contained helpers. The logging functions in utils.c touch globals and I/O, but the utility functions listed here are pure.
color_lights.c (subset) ~200 isShowMode, light color lookup functions, color name→value mapping Many functions are pure lookups and string comparisons. Some functions reference _custom_shows (file-level static), but the core light mode identification is testable.

Estimated test count: ~50 tests covering the foundation functions used everywhere.

Tier 2: Testable with struct setup (takes struct aqualinkdata* or reads _aqconfig_)

These functions are pure in behavior — they read/write struct fields and return results — but need populated structs as input.

File Lines Key testable functions Setup needed
json_messages.c 1,577 parseJSONrequest, build_aqualink_status_JSON, build_device_JSON, build_logmsg_JSON, build_mqtt_status_JSON, LED2text struct JSONkvptr for parsing; struct aqualinkdata for builders
aq_panel.c 2,021 Panel type detection, button initialization, setPanel* functions struct aqualinkdata + struct aqconfig
aq_programmer.c 1,186 Validation helpers, programmer type identification struct aqualinkdata
devices_jandy.c 1,526 processPacketToJandyDevice, processPacketFromSWG, isSWGDeviceErrorState, setSWGdeviceStatus, processPacketFromJandyPump, getPumpStatus struct aqualinkdata + raw packet byte arrays
devices_pentair.c 251 processPacketFromPentairPump, Pentair status parsing struct aqualinkdata + Pentair packet bytes
packetLogger.c 224 beautifyPacket, packet-to-string formatting Raw packet byte arrays

Estimated test count: ~80 tests covering JSON parsing, device packet handling, and panel configuration.

Tier 3: Needs refactoring to test (coupled to file I/O, threads, or network)

These files mix pure logic with I/O. Testing requires either extracting the pure parts or mocking the I/O layer.

File Lines Coupled to Extractable pure logic
config.c 2,540 File I/O (fopen/fclose) setConfigValue() — the core parameter parser that converts string values to typed config. ~800 lines of pure parsing logic trapped behind a file reader.
aq_serial.c 1,345 Serial port (open/read/write/ioctl) Already extracted: generate_checksum, check_jandy_checksum, check_pentair_checksum, generate_pentair_checksum, getProtocolType, getJandyDeviceType. These are pure and critical.
net_services.c 2,393 Mongoose HTTP/WS, pthreads action_URI() parsing logic could be extracted. MQTT message formatting is interleaved with send calls.
sensors.c 240 File I/O, pthreads Sensor value regex parsing could be extracted from read_sensor().
aq_timer.c 228 time(), pthreads Timer remaining calculation is pure if you inject current time.
aq_systemutils.c 326 mount(), fopen, subprocess exec All I/O coupled. No pure logic to extract without refactoring.

Note: The checksum functions in aq_serial.c are already cleanly separated — they're Tier 1 in practice. We list them here because the file overall is I/O-coupled.

Tier 4: Protocol handlers (test via public interface — never modify source)

These files implement the Jandy panel communication protocols. Per project policy, we will not modify this source code. But writing tests against their public interfaces with known packet data is high-value — tests act as a regression safety net for the maintainer's future protocol changes.

File Lines Public interface Test data source
allbutton.c 822 process_allbutton_packet(packet, length, aqdata) LED status packets, command ACKs
onetouch.c 1,098 process_onetouch_packet(packet, length, aqdata) Menu display packets, highlight packets
iaqtouch.c 1,501 process_iaqtouch_packet(packet, length, aqdata) Page navigation, button state, status packets
iaqualink.c 838 process_iaqualink_packet(packet, length, aqdata), process_iAqualinkStatusPacket(...) Main status, aux status, command packets
pda.c 1,097 process_pda_packet(packet, length) Menu display, highlight, clear/shift packets

These are future work (Phase 2+). Testing them requires capturing real packet sequences from each panel type — something the maintainer has direct access to.


3. Where Testing Has the Most Value

Ranked by risk (external input handling, buffer safety, correctness):

1. JSON parsing: parseJSONrequest — external input, no validation

This is the highest-risk function in the codebase. It receives raw input from web clients, destructively modifies the buffer, and has no bounds checking:

// json_messages.c:1029 — the entire parser
bool parseJSONrequest(char *buffer, struct JSONkvptr *request)
{
  int i=0;
  int found=0;
  bool reading = false;

  // Initialize 4 key-value slots
  request->kv[0].key = NULL;  request->kv[0].value = NULL;
  request->kv[1].key = NULL;  request->kv[1].value = NULL;
  request->kv[2].key = NULL;  request->kv[2].value = NULL;
  request->kv[3].key = NULL;  request->kv[3].value = NULL;

  int length = strlen(buffer);

  while ( i < length ) {
    switch (buffer[i]) {
    case '{': case '"': case '}': case ':': case ',': case ' ':
      if (reading == true && buffer[i] != ' ' && buffer[i] != ',' && buffer[i] != ':'){
        reading = false;
        buffer[i] = '\0';  // destructive modification
        found++;
      }
      break;
    default:
      if (reading == false) {
        reading = true;
        if ( found%2 == 0 )
          request->kv[found / 2].key = &buffer[i];
        else
          request->kv[(found-1) / 2].value = &buffer[i];
      }
      break;
    }
    if (found >= 8) break;
    i++;
  }
  return true;  // always returns true — no error detection
}

What tests catch:

  • Normal case: {"uri":"/api/setpoint","value":"85"} → correct key/value extraction
  • Empty input, empty object {}
  • More than 4 key-value pairs (silent truncation at found >= 8)
  • Values with spaces (the parser ignores spaces as delimiters while reading)
  • Malformed input: missing quotes, missing braces, nested objects

2. Serial checksum functions — correctness-critical, pure

These are the most testable high-value targets. A wrong checksum means dropped or corrupted commands to pool equipment:

// aq_serial.c:323 — Jandy checksum: sum of bytes 0..n-3, masked to 8 bits
int generate_checksum(unsigned char* packet, int length)
{
  int i, sum, n;
  n = length - 3;
  sum = 0;
  for (i = 0; i < n; i++)
    sum += (int) packet[i];
  return(sum & 0x0ff);
}

// aq_serial.c:334 — includes workaround for known Jandy protocol bug
bool check_jandy_checksum(unsigned char* packet, int length)
{
  if (generate_checksum(packet, length) == packet[length-3])
    return true;
  // Bug workaround: OneTouch long msg, line 3, always 0x0a checksum
  if (packet[3] == 0x04 && packet[4] == 0x03 && packet[length-3] == 0x0a) {
    return true;  // known false-negative in Jandy protocol
  }
  return false;
}

// aq_serial.c:357 — Pentair: 16-bit checksum starting at byte 3
bool check_pentair_checksum(unsigned char* packet, int length)
{
  int i, sum, n;
  n = packet[8] + 9;
  sum = 0;
  for (i = 3; i < n; i++)
    sum += (int) packet[i];
  if (sum == (packet[length-2] * 256 + packet[length-1]))
    return true;
  if (sum == (packet[n] * 256 + packet[n+1]))  // length mismatch recovery
    return true;
  return false;
}

What tests catch:

  • Known-good packets from real hardware (golden test vectors)
  • Off-by-one in checksum range
  • The Jandy protocol bug workaround (ensure it fires for the right packet pattern and doesn't fire for others)
  • Pentair length field vs actual length mismatch handling

3. Config value parsing — type conversion edge cases

setConfigValue() in config.c is the central config parser. It converts raw string values from the config file into typed values (int, bool, hex, float, bitmask) and stores them into struct aqconfig:

// config.c — type conversion switch (simplified)
switch (_cfgParams[i].value_type) {
  case CFG_STRING:
    *(char **)_cfgParams[i].value_ptr = cleanalloc(value);
    break;
  case CFG_INT:
    *(int *)_cfgParams[i].value_ptr = strtoul(cleanwhitespace(value), NULL, 10);
    break;
  case CFG_BOOL:
    *(bool *)_cfgParams[i].value_ptr = text2bool(value);
    break;
  case CFG_HEX:
    *(unsigned char *)_cfgParams[i].value_ptr = strtoul(cleanwhitespace(value), NULL, 16);
    break;
  case CFG_FLOAT:
    *(float *)_cfgParams[i].value_ptr = atof(tmpval);
    break;
  case CFG_BITMASK:
    if (text2bool(value))
      *(uint16_t *)_cfgParams[i].value_ptr |= _cfgParams[i].mask;
    else
      *(uint16_t *)_cfgParams[i].value_ptr &= ~_cfgParams[i].mask;
    break;
}

What tests catch:

  • strtoul overflow (e.g., "999999" for a unsigned char hex field)
  • Whitespace handling: " 0x0a " vs "0x0a" vs "0x0A"
  • Boolean edge cases: "yes", "YES", "on", "ON", "true" (note: text2bool only recognizes YES/ON)
  • Empty or blank values triggering default fallback

4. String utilities — foundation functions used everywhere

These are called from almost every file. A bug here ripples through the entire system:

// utils.c — used in config parsing, message handling, everywhere
char *cleanwhitespace(char *str) {
  char *end;
  if (str == NULL) return str;
  while(isspace(*str)) str++;
  if(*str == 0) return str;
  end = str + strlen(str) - 1;
  while(end > str && isspace(*end)) end--;
  if (end != (str + strlen(str) - 1) )
    *(end+1) = 0;
  return str;
}

// rs_msg_utils.c — RS-485 message string handling
int rsm_strncpy(char *dest, const unsigned char *src, int dest_len, int src_len);
int rsm_strcmp(const char *s1, const char *s2);
char *rsm_strnstr(const char *haystack, const char *needle, size_t slen);
int rsm_atoi(const char* str);
float rsm_atof(const char* str);
int rsm_HHMM2min(char *message);

What tests catch:

  • NULL input handling
  • All-whitespace strings
  • Empty strings
  • Strings with only leading or only trailing whitespace
  • rsm_atoi/rsm_atof with non-numeric input
  • rsm_HHMM2min with malformed time strings

5. JSON builders — can catch escaping/overflow bugs

The build_*_JSON functions use sprintf chains to construct JSON. Tests verify the output is valid JSON and contains expected field values:

What tests catch:

  • Buffer overflow (output exceeds JSON_BUFFER_SIZE)
  • Invalid JSON from unescaped special characters in aqdata->last_message
  • Missing or extra commas/braces
  • Correct field values for known struct inputs

4. Framework Recommendation: Unity

Why Unity

Unity is a test framework designed for C, especially embedded C. It's three files (MIT license):

File Lines Purpose
unity.c ~1,300 Test runner and assertion implementations
unity.h ~600 Public API (assertions, test macros)
unity_internals.h ~800 Internal implementation details

Comparison with alternatives

Factor Unity cmocka Check CUnit
Files to vendor 3 6+ 15+ 20+
Dependencies None None fork()/setjmp None
License MIT Apache 2.0 LGPL LGPL
Designed for embedded C Yes Yes No No
Assertion readability TEST_ASSERT_EQUAL(85, temp) assert_int_equal(85, temp) ck_assert_int_eq(85, temp) CU_ASSERT_EQUAL(85, temp)
Setup/teardown Built-in Built-in Built-in Built-in
Binary size impact ~15 KB ~30 KB ~100 KB ~50 KB
Build integration Add 1 .c file pkg-config or vendor pkg-config pkg-config
Cross-compile friendly Yes (no OS deps) Yes No (fork) Mostly

Why not the others

  • cmocka: Good framework, but Unity is simpler for our needs — we don't need mocking for Tier 1/2 tests.
  • Check: Uses fork() for test isolation, which adds complexity and makes debugging harder. LGPL license.
  • CUnit: Heavier, less commonly used in modern embedded C projects. LGPL license.
  • Google Test: C++, not C. Would require wrapping all test files in extern "C".

What a Unity test looks like

// test/test_rs_msg_utils.c
#include "unity.h"
#include "rs_msg_utils.h"

void setUp(void) {}
void tearDown(void) {}

void test_rsm_atoi_normal(void) {
    TEST_ASSERT_EQUAL_INT(42, rsm_atoi("42"));
    TEST_ASSERT_EQUAL_INT(0, rsm_atoi("0"));
    TEST_ASSERT_EQUAL_INT(-5, rsm_atoi("-5"));
}

void test_rsm_atoi_with_whitespace(void) {
    TEST_ASSERT_EQUAL_INT(42, rsm_atoi("  42  "));
}

void test_rsm_strnstr_found(void) {
    TEST_ASSERT_NOT_NULL(rsm_strnstr("REV T.0.1", "REV", 9));
}

void test_rsm_strnstr_not_found(void) {
    TEST_ASSERT_NULL(rsm_strnstr("hello world", "REV", 11));
}

void test_rsm_HHMM2min_normal(void) {
    TEST_ASSERT_EQUAL_INT(150, rsm_HHMM2min("2:30"));
    TEST_ASSERT_EQUAL_INT(60, rsm_HHMM2min("1:00"));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_rsm_atoi_normal);
    RUN_TEST(test_rsm_atoi_with_whitespace);
    RUN_TEST(test_rsm_strnstr_found);
    RUN_TEST(test_rsm_strnstr_not_found);
    RUN_TEST(test_rsm_HHMM2min_normal);
    return UNITY_END();
}
// test/test_checksum.c
#include "unity.h"
#include "aq_serial.h"

void setUp(void) {}
void tearDown(void) {}

void test_jandy_checksum_known_good_packet(void) {
    // Real packet: probe to device 0x0a
    // DLE STX DEST CMD ... CHECKSUM DLE ETX
    unsigned char packet[] = {0x10, 0x02, 0x0a, 0x00, 0x1c, 0x10, 0x03};
    TEST_ASSERT_TRUE(check_jandy_checksum(packet, 7));
}

void test_jandy_checksum_bad_packet(void) {
    unsigned char packet[] = {0x10, 0x02, 0x0a, 0x00, 0xFF, 0x10, 0x03};
    TEST_ASSERT_FALSE(check_jandy_checksum(packet, 7));
}

void test_jandy_checksum_protocol_bug_workaround(void) {
    // OneTouch long msg, line 3, checksum 0x0a — known Jandy bug
    unsigned char packet[] = {
        0x10, 0x02, 0x43, 0x04, 0x03, 0x20, 0x20, 0x20,
        0x20, 0x35, 0x3a, 0x30, 0x35, 0x20, 0x50, 0x4d,
        0x20, 0x20, 0x20, 0x20, 0x20, 0x0a, 0x10, 0x03
    };
    // Checksum doesn't match, but the workaround should return true
    TEST_ASSERT_TRUE(check_jandy_checksum(packet, 24));
}

void test_generate_checksum_simple(void) {
    // DLE(0x10) + STX(0x02) + DEST(0x0a) + CMD(0x00) = sum of first n-3 bytes
    unsigned char packet[] = {0x10, 0x02, 0x0a, 0x00, 0x00, 0x10, 0x03};
    int checksum = generate_checksum(packet, 7);
    // Sum of bytes 0..3 = 0x10 + 0x02 + 0x0a + 0x00 = 0x1c
    TEST_ASSERT_EQUAL_HEX8(0x1c, checksum);
}

void test_pentair_checksum_known_good(void) {
    // Pentair packet: FF 00 FF A5 [version] [dest] [src] [cmd] [datalen] [data...] [chk_hi] [chk_lo]
    unsigned char packet[] = {
        0xFF, 0x00, 0xFF, 0xA5, 0x00, 0x60, 0x10, 0x07,
        0x0F,  // datalen = 15
        0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x01, 0x95  // checksum high, low
    };
    TEST_ASSERT_TRUE(check_pentair_checksum(packet, 26));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_jandy_checksum_known_good_packet);
    RUN_TEST(test_jandy_checksum_bad_packet);
    RUN_TEST(test_jandy_checksum_protocol_bug_workaround);
    RUN_TEST(test_generate_checksum_simple);
    RUN_TEST(test_pentair_checksum_known_good);
    return UNITY_END();
}
// test/test_json_parse.c
#include "unity.h"
#include "json_messages.h"
#include <string.h>

void setUp(void) {}
void tearDown(void) {}

void test_parseJSON_normal_request(void) {
    char buffer[] = "{\"uri\":\"/api/setpoint\",\"value\":\"85\"}";
    struct JSONkvptr request;
    parseJSONrequest(buffer, &request);
    TEST_ASSERT_EQUAL_STRING("uri", request.kv[0].key);
    TEST_ASSERT_EQUAL_STRING("/api/setpoint", request.kv[0].value);
    TEST_ASSERT_EQUAL_STRING("value", request.kv[1].key);
    TEST_ASSERT_EQUAL_STRING("85", request.kv[1].value);
}

void test_parseJSON_empty_object(void) {
    char buffer[] = "{}";
    struct JSONkvptr request;
    parseJSONrequest(buffer, &request);
    TEST_ASSERT_NULL(request.kv[0].key);
}

void test_parseJSON_four_pairs_max(void) {
    char buffer[] = "{\"a\":\"1\",\"b\":\"2\",\"c\":\"3\",\"d\":\"4\",\"e\":\"5\"}";
    struct JSONkvptr request;
    parseJSONrequest(buffer, &request);
    // Only 4 pairs fit — 5th should be silently dropped
    TEST_ASSERT_EQUAL_STRING("a", request.kv[0].key);
    TEST_ASSERT_EQUAL_STRING("d", request.kv[3].key);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_parseJSON_normal_request);
    RUN_TEST(test_parseJSON_empty_object);
    RUN_TEST(test_parseJSON_four_pairs_max);
    return UNITY_END();
}
// test/test_utils.c
#include "unity.h"
#include "utils.h"
#include <string.h>

void setUp(void) {}
void tearDown(void) {}

void test_degFtoC(void) {
    TEST_ASSERT_FLOAT_WITHIN(0.01, 0.0, degFtoC(32.0));
    TEST_ASSERT_FLOAT_WITHIN(0.01, 100.0, degFtoC(212.0));
    TEST_ASSERT_FLOAT_WITHIN(0.01, 37.0, degFtoC(98.6));
}

void test_degCtoF(void) {
    TEST_ASSERT_FLOAT_WITHIN(0.01, 32.0, degCtoF(0.0));
    TEST_ASSERT_FLOAT_WITHIN(0.01, 212.0, degCtoF(100.0));
}

void test_text2bool(void) {
    char yes[] = "YES";
    char on[] = "ON";
    char no[] = "NO";
    char off[] = "anything_else";
    TEST_ASSERT_TRUE(text2bool(yes));
    TEST_ASSERT_TRUE(text2bool(on));
    TEST_ASSERT_FALSE(text2bool(no));
    TEST_ASSERT_FALSE(text2bool(off));
}

void test_cleanwhitespace_leading_trailing(void) {
    char str[] = "  hello  ";
    char *result = cleanwhitespace(str);
    TEST_ASSERT_EQUAL_STRING("hello", result);
}

void test_cleanwhitespace_null(void) {
    TEST_ASSERT_NULL(cleanwhitespace(NULL));
}

void test_cleanwhitespace_all_spaces(void) {
    char str[] = "    ";
    char *result = cleanwhitespace(str);
    TEST_ASSERT_EQUAL_STRING("", result);
}

void test_count_characters(void) {
    TEST_ASSERT_EQUAL_INT(3, count_characters("a,b,c,d", ','));
    TEST_ASSERT_EQUAL_INT(0, count_characters("hello", ','));
}

void test_getTemperatureUOM(void) {
    // °F = bytes 194, 176, 'F'
    char fahrenheit[] = {194, 176, 'F', '\0'};
    TEST_ASSERT_EQUAL(FAHRENHEIT, getTemperatureUOM(fahrenheit));

    char celsius[] = {194, 176, 'C', '\0'};
    TEST_ASSERT_EQUAL(CELSIUS, getTemperatureUOM(celsius));

    TEST_ASSERT_EQUAL(UNKNOWN, getTemperatureUOM(NULL));
    TEST_ASSERT_EQUAL(UNKNOWN, getTemperatureUOM("xyz"));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_degFtoC);
    RUN_TEST(test_degCtoF);
    RUN_TEST(test_text2bool);
    RUN_TEST(test_cleanwhitespace_leading_trailing);
    RUN_TEST(test_cleanwhitespace_null);
    RUN_TEST(test_cleanwhitespace_all_spaces);
    RUN_TEST(test_count_characters);
    RUN_TEST(test_getTemperatureUOM);
    return UNITY_END();
}

5. Proposed First PR Scope

The first PR is infrastructure + Tier 1 tests only. No source modifications. No refactoring.

What the PR contains

test/
├── unity/
│   ├── unity.c              # vendored (MIT)
│   ├── unity.h              # vendored (MIT)
│   └── unity_internals.h    # vendored (MIT)
├── test_rs_msg_utils.c      # ~20 tests: string matching, copying, atoi, revision parsing
├── test_checksum.c          # ~15 tests: Jandy/Pentair checksum generate + verify
├── test_utils.c             # ~25 tests: whitespace, bool, temp conversion, string helpers
├── test_json_parse.c        # ~10 tests: parseJSONrequest edge cases
└── test_timespec.c          # ~5 tests: timespec_subtract

Makefile addition

# Test configuration
TEST_DIR := ./test
TEST_BUILD_DIR := $(OBJ_DIR)/test
UNITY_DIR := $(TEST_DIR)/unity
UNITY_SRC := $(UNITY_DIR)/unity.c

# Test sources (each builds into its own test binary)
TEST_SRCS := $(wildcard $(TEST_DIR)/test_*.c)
TEST_BINS := $(patsubst $(TEST_DIR)/%.c,$(TEST_BUILD_DIR)/%,$(TEST_SRCS))

# Test compile flags (debug, no optimization)
TEST_CFLAGS = -Wall -O0 -g $(AQ_FLAGS) $(MGFLAGS)

# Build and run all tests
test: $(TEST_BINS)
	@echo "Running tests..."
	@for t in $(TEST_BINS); do echo "--- $$t ---"; $$t || exit 1; done
	@echo "All tests passed."

# Compile each test binary: test source + unity + required source files
$(TEST_BUILD_DIR)/test_rs_msg_utils: $(TEST_DIR)/test_rs_msg_utils.c $(UNITY_SRC) $(SRC_DIR)/rs_msg_utils.c $(SRC_DIR)/utils.c | $(TEST_BUILD_DIR)
	$(CC) $(TEST_CFLAGS) $(INCLUDES) -I$(UNITY_DIR) -o $@ $^ $(LIBS)

$(TEST_BUILD_DIR)/test_checksum: $(TEST_DIR)/test_checksum.c $(UNITY_SRC) $(SRC_DIR)/aq_serial.c $(SRC_DIR)/utils.c $(SRC_DIR)/packetLogger.c $(SRC_DIR)/rs_msg_utils.c $(SRC_DIR)/timespec_subtract.c | $(TEST_BUILD_DIR)
	$(CC) $(TEST_CFLAGS) $(INCLUDES) -I$(UNITY_DIR) -o $@ $^ $(LIBS)

$(TEST_BUILD_DIR)/test_utils: $(TEST_DIR)/test_utils.c $(UNITY_SRC) $(SRC_DIR)/utils.c | $(TEST_BUILD_DIR)
	$(CC) $(TEST_CFLAGS) $(INCLUDES) -I$(UNITY_DIR) -o $@ $^ $(LIBS)

$(TEST_BUILD_DIR)/test_json_parse: $(TEST_DIR)/test_json_parse.c $(UNITY_SRC) $(SRC_DIR)/json_messages.c $(SRC_DIR)/utils.c $(SRC_DIR)/rs_msg_utils.c | $(TEST_BUILD_DIR)
	$(CC) $(TEST_CFLAGS) $(INCLUDES) -I$(UNITY_DIR) -o $@ $^ $(LIBS)

$(TEST_BUILD_DIR)/test_timespec: $(TEST_DIR)/test_timespec.c $(UNITY_SRC) $(SRC_DIR)/timespec_subtract.c | $(TEST_BUILD_DIR)
	$(CC) $(TEST_CFLAGS) $(INCLUDES) -I$(UNITY_DIR) -o $@ $^ $(LIBS)

$(TEST_BUILD_DIR):
	$(MKDIR) $(call FixPath,$@)

What the PR does NOT contain

  • No changes to any existing source files
  • No new dependencies beyond Unity's 3 files (MIT license)
  • No build system changes that affect the production binary
  • No protocol handler tests (those need real packet data from the maintainer)

Output when you run make test

$ make test
Running tests...
--- ./build/test/test_rs_msg_utils ---
test/test_rs_msg_utils.c:10:test_rsm_atoi_normal:PASS
test/test_rs_msg_utils.c:16:test_rsm_atoi_with_whitespace:PASS
test/test_rs_msg_utils.c:20:test_rsm_strnstr_found:PASS
...
20 Tests 0 Failures 0 Ignored
--- ./build/test/test_checksum ---
test/test_checksum.c:8:test_jandy_checksum_known_good_packet:PASS
test/test_checksum.c:14:test_jandy_checksum_bad_packet:PASS
...
15 Tests 0 Failures 0 Ignored
--- ./build/test/test_utils ---
...
25 Tests 0 Failures 0 Ignored
--- ./build/test/test_json_parse ---
...
10 Tests 0 Failures 0 Ignored
--- ./build/test/test_timespec ---
...
5 Tests 0 Failures 0 Ignored
All tests passed.

6. Phased Roadmap

Phase Scope Source changes? Tests added
1 (this PR) Infrastructure + Tier 1 None ~75 tests
2 Tier 2: JSON builders, device packet parsing None (tests only) ~50 tests
3 Tier 2: aq_panel.c configuration, aq_programmer.c validation None (tests only) ~30 tests
4 Tier 3: Extract setConfigValue tests (config parsing) Minimal (may need to expose internal helpers) ~20 tests
5 Tier 4: Protocol handler regression tests (with real packet captures) None ~40 tests

Phase 1 is self-contained and immediately valuable. Each subsequent phase is independent and can be prioritized based on where bugs actually occur.


Appendix: Full File Inventory

File Lines Tier Testable surface
rs_msg_utils.c 577 1 16 pure string functions
timespec_subtract.c 46 1 1 pure function
debug_timer.c 75 1 Timer utilities
utils.c 1,028 1/3 ~18 pure helpers + logging (I/O)
color_lights.c 558 1/2 Light mode lookups (some statics)
json_messages.c 1,577 2 Parser + 12 JSON builders
aq_panel.c 2,021 2 Panel config and button setup
aq_programmer.c 1,186 2/3 Validation helpers + thread-based programmer
devices_jandy.c 1,526 2 Packet processors, SWG/pump status
devices_pentair.c 251 2 Pentair pump packet parsing
packetLogger.c 224 2 beautifyPacket formatting
mqtt_discovery.c 766 2/3 MQTT discovery message building
config.c 2,540 3 setConfigValue parser (extractable)
aq_serial.c 1,345 1/3 Checksums (pure) + serial I/O
net_services.c 2,393 3 URI parsing (extractable) + network I/O
sensors.c 240 3 Regex parsing + file I/O
aq_timer.c 228 3 Timer calc + threading
aq_systemutils.c 326 3 All I/O coupled
web_config.c 183 3 JSON config save/load
aq_scheduler.c 412 3 Scheduler logic + time
auto_configure.c 291 2/3 Auto-detection logic
serialadapter.c 1,415 3 RS serial adapter I/O
simulator.c 138 2/3 Simulator packet building
net_interface.c 191 3 Network interface detection
allbutton.c 822 4 Protocol handler (read-only)
allbutton_aq_programmer.c 1,616 4 Protocol programmer (read-only)
onetouch.c 1,098 4 Protocol handler (read-only)
onetouch_aq_programmer.c 1,086 4 Protocol programmer (read-only)
iaqtouch.c 1,501 4 Protocol handler (read-only)
iaqtouch_aq_programmer.c 1,761 4 Protocol programmer (read-only)
iaqualink.c 838 4 Protocol handler (read-only)
pda.c 1,097 4 Protocol handler (read-only)
pda_aq_programmer.c 2,092 4 Protocol programmer (read-only)
pda_menu.c 392 4 PDA menu state (read-only)
aqualinkd.c 1,427 3 Main loop (not testable)
dummy_device.c 253 Test utility (already exists)
dummy_reader.c 187 Test utility (already exists)
rs485mon.c 1,214 3 RS-485 monitor daemon
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment