Anvil v1.0.0 -- Arduino build tool with HAL and test scaffolding

Single-binary CLI that scaffolds testable Arduino projects, compiles,
uploads, and monitors serial output. Templates embed a hardware
abstraction layer, Google Mock infrastructure, and CMake-based host
tests so application logic can be verified without hardware.

Commands: new, doctor, setup, devices, build, upload, monitor
39 Rust tests (21 unit, 18 integration)
Cross-platform: Linux and Windows
This commit is contained in:
Eric Ratliff
2026-02-15 11:16:17 -06:00
commit 3298844399
41 changed files with 4866 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
cmake_minimum_required(VERSION 3.14)
project({{PROJECT_NAME}}_tests LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# --------------------------------------------------------------------------
# Google Test (fetched automatically on first build)
# --------------------------------------------------------------------------
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
enable_testing()
# --------------------------------------------------------------------------
# Include paths -- same headers the Arduino sketch uses
# --------------------------------------------------------------------------
set(LIB_DIR ${CMAKE_SOURCE_DIR}/../lib)
include_directories(
${LIB_DIR}/hal
${LIB_DIR}/app
${CMAKE_SOURCE_DIR}/mocks
)
# --------------------------------------------------------------------------
# Unit tests (Google Mock)
# --------------------------------------------------------------------------
add_executable(test_unit
test_unit.cpp
)
target_link_libraries(test_unit
GTest::gtest_main
GTest::gmock
)
# --------------------------------------------------------------------------
# Register with CTest
# --------------------------------------------------------------------------
include(GoogleTest)
gtest_discover_tests(test_unit)

View File

@@ -0,0 +1,45 @@
#ifndef MOCK_HAL_H
#define MOCK_HAL_H
#include <gmock/gmock.h>
#include "hal.h"
/*
* StrictMock-friendly HAL mock for unit tests.
*
* Use this when you want to verify exact call sequences:
* EXPECT_CALL(mock, digitalWrite(13, HIGH)).Times(1);
*/
class MockHal : public Hal {
public:
// GPIO
MOCK_METHOD(void, pinMode, (uint8_t pin, uint8_t mode), (override));
MOCK_METHOD(void, digitalWrite, (uint8_t pin, uint8_t value), (override));
MOCK_METHOD(uint8_t, digitalRead, (uint8_t pin), (override));
MOCK_METHOD(int, analogRead, (uint8_t pin), (override));
MOCK_METHOD(void, analogWrite, (uint8_t pin, int value), (override));
// Timing
MOCK_METHOD(unsigned long, millis, (), (override));
MOCK_METHOD(unsigned long, micros, (), (override));
MOCK_METHOD(void, delay, (unsigned long ms), (override));
MOCK_METHOD(void, delayMicroseconds, (unsigned long us), (override));
// Serial
MOCK_METHOD(void, serialBegin, (unsigned long baud), (override));
MOCK_METHOD(void, serialPrint, (const char* msg), (override));
MOCK_METHOD(void, serialPrintln, (const char* msg), (override));
MOCK_METHOD(int, serialAvailable, (), (override));
MOCK_METHOD(int, serialRead, (), (override));
// I2C
MOCK_METHOD(void, i2cBegin, (), (override));
MOCK_METHOD(void, i2cBeginTransmission, (uint8_t addr), (override));
MOCK_METHOD(size_t, i2cWrite, (uint8_t data), (override));
MOCK_METHOD(uint8_t, i2cEndTransmission, (), (override));
MOCK_METHOD(uint8_t, i2cRequestFrom, (uint8_t addr, uint8_t count), (override));
MOCK_METHOD(int, i2cAvailable, (), (override));
MOCK_METHOD(int, i2cRead, (), (override));
};
#endif // MOCK_HAL_H

View File

@@ -0,0 +1,256 @@
#ifndef SIM_HAL_H
#define SIM_HAL_H
#include "hal.h"
#include <cstdio>
#include <cstring>
#include <functional>
#include <map>
#include <queue>
#include <string>
#include <vector>
/*
* Simulated HAL for system tests.
*
* Unlike MockHal (which verifies call expectations), SimHal actually
* maintains state: pin values, a virtual clock, serial output capture,
* and pluggable I2C device simulators.
*
* This lets you write system tests that exercise full application logic
* against simulated hardware:
*
* SimHal sim;
* BlinkApp app(&sim);
* app.begin();
*
* sim.setPin(2, LOW); // simulate button press
* sim.advanceMillis(600); // advance clock
* app.update();
*
* EXPECT_EQ(sim.getPin(13), HIGH); // check LED state
*/
// --------------------------------------------------------------------
// I2C device simulator interface
// --------------------------------------------------------------------
class I2cDeviceSim {
public:
virtual ~I2cDeviceSim() = default;
// Called when master writes bytes to this device
virtual void onReceive(const uint8_t* data, size_t len) = 0;
// Called when master requests bytes; fill response buffer
virtual size_t onRequest(uint8_t* buf, size_t max_len) = 0;
};
// --------------------------------------------------------------------
// Simulated HAL
// --------------------------------------------------------------------
class SimHal : public Hal {
public:
static const int NUM_PINS = 20; // D0-D13 + A0-A5
SimHal() : clock_ms_(0), clock_us_(0) {
memset(pin_modes_, 0, sizeof(pin_modes_));
memset(pin_values_, 0, sizeof(pin_values_));
}
// -- GPIO ---------------------------------------------------------------
void pinMode(uint8_t pin, uint8_t mode) override {
if (pin < NUM_PINS) {
pin_modes_[pin] = mode;
// INPUT_PULLUP defaults to HIGH
if (mode == INPUT_PULLUP) {
pin_values_[pin] = HIGH;
}
}
}
void digitalWrite(uint8_t pin, uint8_t value) override {
if (pin < NUM_PINS) {
pin_values_[pin] = value;
gpio_log_.push_back({clock_ms_, pin, value});
}
}
uint8_t digitalRead(uint8_t pin) override {
if (pin < NUM_PINS) return pin_values_[pin];
return LOW;
}
int analogRead(uint8_t pin) override {
if (pin < NUM_PINS) return analog_values_[pin];
return 0;
}
void analogWrite(uint8_t pin, int value) override {
if (pin < NUM_PINS) pin_values_[pin] = (value > 0) ? HIGH : LOW;
}
// -- Timing -------------------------------------------------------------
unsigned long millis() override { return clock_ms_; }
unsigned long micros() override { return clock_us_; }
void delay(unsigned long ms) override { advanceMillis(ms); }
void delayMicroseconds(unsigned long us) override { clock_us_ += us; }
// -- Serial -------------------------------------------------------------
void serialBegin(unsigned long baud) override { (void)baud; }
void serialPrint(const char* msg) override {
serial_output_ += msg;
}
void serialPrintln(const char* msg) override {
serial_output_ += msg;
serial_output_ += "\n";
}
int serialAvailable() override {
return static_cast<int>(serial_input_.size());
}
int serialRead() override {
if (serial_input_.empty()) return -1;
int c = serial_input_.front();
serial_input_.erase(serial_input_.begin());
return c;
}
// -- I2C ----------------------------------------------------------------
void i2cBegin() override {}
void i2cBeginTransmission(uint8_t addr) override {
i2c_addr_ = addr;
i2c_tx_buf_.clear();
}
size_t i2cWrite(uint8_t data) override {
i2c_tx_buf_.push_back(data);
return 1;
}
uint8_t i2cEndTransmission() override {
auto it = i2c_devices_.find(i2c_addr_);
if (it == i2c_devices_.end()) return 2; // NACK on address
it->second->onReceive(i2c_tx_buf_.data(), i2c_tx_buf_.size());
return 0; // success
}
uint8_t i2cRequestFrom(uint8_t addr, uint8_t count) override {
i2c_rx_buf_.clear();
auto it = i2c_devices_.find(addr);
if (it == i2c_devices_.end()) return 0;
uint8_t tmp[256];
size_t n = it->second->onRequest(tmp, count);
for (size_t i = 0; i < n; ++i) {
i2c_rx_buf_.push_back(tmp[i]);
}
return static_cast<uint8_t>(n);
}
int i2cAvailable() override {
return static_cast<int>(i2c_rx_buf_.size());
}
int i2cRead() override {
if (i2c_rx_buf_.empty()) return -1;
int val = i2c_rx_buf_.front();
i2c_rx_buf_.erase(i2c_rx_buf_.begin());
return val;
}
// ====================================================================
// Test control API (not part of Hal interface)
// ====================================================================
// -- Clock control ------------------------------------------------------
void advanceMillis(unsigned long ms) {
clock_ms_ += ms;
clock_us_ += ms * 1000;
}
void setMillis(unsigned long ms) {
clock_ms_ = ms;
clock_us_ = ms * 1000;
}
// -- GPIO control -------------------------------------------------------
void setPin(uint8_t pin, uint8_t value) {
if (pin < NUM_PINS) pin_values_[pin] = value;
}
uint8_t getPin(uint8_t pin) const {
if (pin < NUM_PINS) return pin_values_[pin];
return LOW;
}
uint8_t getPinMode(uint8_t pin) const {
if (pin < NUM_PINS) return pin_modes_[pin];
return 0;
}
void setAnalog(uint8_t pin, int value) {
analog_values_[pin] = value;
}
// -- GPIO history -------------------------------------------------------
struct GpioEvent {
unsigned long timestamp_ms;
uint8_t pin;
uint8_t value;
};
const std::vector<GpioEvent>& gpioLog() const { return gpio_log_; }
void clearGpioLog() { gpio_log_.clear(); }
// Count how many times a pin was set to a specific value
int countWrites(uint8_t pin, uint8_t value) const {
int count = 0;
for (const auto& e : gpio_log_) {
if (e.pin == pin && e.value == value) ++count;
}
return count;
}
// -- Serial control -----------------------------------------------------
const std::string& serialOutput() const { return serial_output_; }
void clearSerialOutput() { serial_output_.clear(); }
void injectSerialInput(const std::string& data) {
for (char c : data) {
serial_input_.push_back(static_cast<uint8_t>(c));
}
}
// -- I2C device registration --------------------------------------------
void attachI2cDevice(uint8_t addr, I2cDeviceSim* device) {
i2c_devices_[addr] = device;
}
void detachI2cDevice(uint8_t addr) {
i2c_devices_.erase(addr);
}
private:
// GPIO
uint8_t pin_modes_[NUM_PINS];
uint8_t pin_values_[NUM_PINS];
std::map<uint8_t, int> analog_values_;
std::vector<GpioEvent> gpio_log_;
// Timing
unsigned long clock_ms_;
unsigned long clock_us_;
// Serial
std::string serial_output_;
std::vector<uint8_t> serial_input_;
// I2C
uint8_t i2c_addr_ = 0;
std::vector<uint8_t> i2c_tx_buf_;
std::vector<uint8_t> i2c_rx_buf_;
std::map<uint8_t, I2cDeviceSim*> i2c_devices_;
};
#endif // SIM_HAL_H

View File

@@ -0,0 +1,42 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
set BUILD_DIR=%SCRIPT_DIR%build
if "%1"=="--clean" (
if exist "%BUILD_DIR%" (
echo Cleaning build directory...
rmdir /s /q "%BUILD_DIR%"
)
)
if not exist "%BUILD_DIR%\CMakeCache.txt" (
echo Configuring (first run will fetch Google Test)...
cmake -S "%SCRIPT_DIR%" -B "%BUILD_DIR%" -DCMAKE_BUILD_TYPE=Debug
if errorlevel 1 (
echo FAIL: cmake configure failed
exit /b 1
)
)
echo Building tests...
cmake --build "%BUILD_DIR%" --parallel
if errorlevel 1 (
echo FAIL: build failed
exit /b 1
)
echo.
echo Running tests...
echo.
ctest --test-dir "%BUILD_DIR%" --output-on-failure
if errorlevel 1 (
echo.
echo FAIL: Some tests failed.
exit /b 1
)
echo.
echo PASS: All tests passed.

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
#
# run_tests.sh -- Build and run host-side unit tests
#
# Usage:
# ./test/run_tests.sh Build and run all tests
# ./test/run_tests.sh --clean Clean rebuild
# ./test/run_tests.sh --verbose Verbose test output
#
# Prerequisites:
# cmake >= 3.14, g++ or clang++, git (for fetching gtest)
#
# First run will download Google Test (~30 seconds).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
# Color output
if [[ -t 1 ]]; then
RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m'
BLD=$'\033[1m'; RST=$'\033[0m'
else
RED=''; GRN=''; CYN=''; BLD=''; RST=''
fi
info() { echo -e "${CYN}[TEST]${RST} $*"; }
ok() { echo -e "${GRN}[PASS]${RST} $*"; }
die() { echo -e "${RED}[FAIL]${RST} $*" >&2; exit 1; }
DO_CLEAN=0
VERBOSE=""
for arg in "$@"; do
case "$arg" in
--clean) DO_CLEAN=1 ;;
--verbose) VERBOSE="--verbose" ;;
*) die "Unknown option: $arg" ;;
esac
done
command -v cmake &>/dev/null || die "cmake not found. Install: sudo apt install cmake"
command -v g++ &>/dev/null || command -v clang++ &>/dev/null || die "No C++ compiler found"
command -v git &>/dev/null || die "git not found (needed to fetch Google Test)"
if [[ $DO_CLEAN -eq 1 ]] && [[ -d "$BUILD_DIR" ]]; then
info "Cleaning build directory..."
rm -rf "$BUILD_DIR"
fi
if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
info "Configuring (first run will fetch Google Test)..."
cmake -S "$SCRIPT_DIR" -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Debug
fi
info "Building tests..."
cmake --build "$BUILD_DIR" --parallel
echo ""
info "${BLD}Running tests...${RST}"
echo ""
CTEST_ARGS=("--test-dir" "$BUILD_DIR" "--output-on-failure")
[[ -n "$VERBOSE" ]] && CTEST_ARGS+=("--verbose")
if ctest "${CTEST_ARGS[@]}"; then
echo ""
ok "${BLD}All tests passed.${RST}"
else
echo ""
die "Some tests failed."
fi

View File

@@ -0,0 +1,124 @@
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "hal.h"
#include "mock_hal.h"
#include "{{PROJECT_NAME}}_app.h"
using ::testing::_;
using ::testing::AnyNumber;
using ::testing::Return;
using ::testing::HasSubstr;
// ============================================================================
// Unit Tests -- verify exact HAL interactions
// ============================================================================
class BlinkAppUnitTest : public ::testing::Test {
protected:
void SetUp() override {
ON_CALL(mock_, millis()).WillByDefault(Return(0));
ON_CALL(mock_, digitalRead(_)).WillByDefault(Return(HIGH));
EXPECT_CALL(mock_, serialBegin(_)).Times(AnyNumber());
EXPECT_CALL(mock_, serialPrintln(_)).Times(AnyNumber());
EXPECT_CALL(mock_, millis()).Times(AnyNumber());
}
::testing::NiceMock<MockHal> mock_;
};
TEST_F(BlinkAppUnitTest, BeginConfiguresPins) {
BlinkApp app(&mock_, 13, 2);
EXPECT_CALL(mock_, pinMode(13, OUTPUT)).Times(1);
EXPECT_CALL(mock_, pinMode(2, INPUT_PULLUP)).Times(1);
EXPECT_CALL(mock_, serialBegin(115200)).Times(1);
app.begin();
}
TEST_F(BlinkAppUnitTest, StartsInSlowMode) {
BlinkApp app(&mock_);
app.begin();
EXPECT_FALSE(app.fastMode());
EXPECT_EQ(app.interval(), BlinkApp::SLOW_INTERVAL_MS);
}
TEST_F(BlinkAppUnitTest, TogglesLedAfterInterval) {
BlinkApp app(&mock_);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
ON_CALL(mock_, millis()).WillByDefault(Return(500));
EXPECT_CALL(mock_, digitalWrite(13, _)).Times(1);
app.update();
}
TEST_F(BlinkAppUnitTest, DoesNotToggleBeforeInterval) {
BlinkApp app(&mock_);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
ON_CALL(mock_, millis()).WillByDefault(Return(499));
EXPECT_CALL(mock_, digitalWrite(_, _)).Times(0);
app.update();
}
TEST_F(BlinkAppUnitTest, ButtonPressSwitchesToFastMode) {
BlinkApp app(&mock_, 13, 2);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH));
app.begin();
ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW));
EXPECT_CALL(mock_, serialPrintln(HasSubstr("FAST"))).Times(1);
app.update();
EXPECT_TRUE(app.fastMode());
EXPECT_EQ(app.interval(), BlinkApp::FAST_INTERVAL_MS);
}
TEST_F(BlinkAppUnitTest, SecondButtonPressReturnsToSlowMode) {
BlinkApp app(&mock_, 13, 2);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH));
app.begin();
// First press: fast mode
ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW));
app.update();
EXPECT_TRUE(app.fastMode());
// Release
ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH));
app.update();
// Second press: back to slow
ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW));
EXPECT_CALL(mock_, serialPrintln(HasSubstr("SLOW"))).Times(1);
app.update();
EXPECT_FALSE(app.fastMode());
}
TEST_F(BlinkAppUnitTest, HoldingButtonDoesNotRepeatToggle) {
BlinkApp app(&mock_, 13, 2);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(HIGH));
app.begin();
ON_CALL(mock_, digitalRead(2)).WillByDefault(Return(LOW));
app.update();
EXPECT_TRUE(app.fastMode());
// Still held -- should NOT toggle again
app.update();
app.update();
EXPECT_TRUE(app.fastMode());
}