feat: nisps build + host test harness

CMakeLists.txt:
- Native host build by default; Emscripten-target detection plumbed but
  WASM emit deferred to stream 7 (playground build script).
- Header-only INTERFACE library `nisps_core`.
- Host test executable `nisps_core_tests` compiled with -Wall -Wextra
  -Werror -Wpedantic (Chris's rules: clean build is non-negotiable).

tests/cpp/test_helpers.hpp:
- Minimal NISPS_TEST / NISPS_EXPECT / NISPS_EXPECT_NEAR macros, no external
  deps. Rationale documented in-file: Catch2/doctest would add ~10MB and 30s
  for what is currently <100 LOC of test runtime.

22 unit tests covering FixedBuffer (5), RingBuffer (5), Rng (7), math (5).
All green; verified via `cmake --build nisps/build && ./nisps/build/nisps_core_tests`.
This commit is contained in:
w1n5t0n 2026-04-29 15:22:01 +03:00
parent 4f60fc8405
commit e5bf2aa055
7 changed files with 427 additions and 0 deletions

68
nisps/CMakeLists.txt Normal file
View file

@ -0,0 +1,68 @@
cmake_minimum_required(VERSION 3.20)
project(nisps_core CXX)
# ---------------------------------------------------------------------------
# Toolchain / standard
# ---------------------------------------------------------------------------
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Default to a Release build when invoked without -DCMAKE_BUILD_TYPE so host
# tests get optimized math; flip with `-DCMAKE_BUILD_TYPE=Debug` for stepping.
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
endif()
# ---------------------------------------------------------------------------
# Core interface library — header-only. Other parts of the build (ml/, dsp/,
# engines/, modes/) will link against this once they exist.
# ---------------------------------------------------------------------------
add_library(nisps_core INTERFACE)
target_include_directories(nisps_core
INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}
)
target_compile_features(nisps_core INTERFACE cxx_std_20)
# ---------------------------------------------------------------------------
# Emscripten / WASM target detection
# ---------------------------------------------------------------------------
# When building for WASM via emcmake, ${EMSCRIPTEN} is set automatically. We
# don't actually emit a WASM binary from this CMakeLists yet — the WASM build
# script lives in stream 7 (playground/build) and assembles its own
# Emscripten link command. Here we just gate the host-only test executable so
# `emcmake cmake -S nisps -B build-wasm` configures cleanly.
if(EMSCRIPTEN)
message(STATUS "nisps_core: configuring for Emscripten/WASM target")
endif()
# ---------------------------------------------------------------------------
# Test scaffold — only built on the host. We use a hand-rolled assertion-
# based harness (see tests/cpp/test_helpers.hpp) rather than Catch2/doctest;
# the rationale is in test_helpers.hpp.
# ---------------------------------------------------------------------------
if(NOT EMSCRIPTEN)
set(NISPS_TEST_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../tests/cpp)
add_executable(nisps_core_tests
${NISPS_TEST_DIR}/test_main.cpp
${NISPS_TEST_DIR}/test_fixed_buffer.cpp
${NISPS_TEST_DIR}/test_ring_buffer.cpp
${NISPS_TEST_DIR}/test_rng.cpp
${NISPS_TEST_DIR}/test_math.cpp
)
target_link_libraries(nisps_core_tests PRIVATE nisps_core)
# Chris's rule: the core compiles cleanly under -Wall -Wextra -Werror.
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
target_compile_options(nisps_core_tests PRIVATE
-Wall -Wextra -Werror -Wpedantic
)
elseif(MSVC)
target_compile_options(nisps_core_tests PRIVATE /W4 /WX)
endif()
enable_testing()
add_test(NAME nisps_core_tests COMMAND nisps_core_tests)
endif()

View file

@ -0,0 +1,52 @@
// tests/cpp/test_fixed_buffer.cpp — exercises FixedBuffer's cursor semantics
// and capacity guard.
#include "test_helpers.hpp"
#include "../../nisps/core/fixed_buffer.hpp"
NISPS_TEST(fixed_buffer_starts_empty) {
nisps::FixedBuffer<int, 8> b;
NISPS_EXPECT(b.size() == 0u);
NISPS_EXPECT(b.empty());
NISPS_EXPECT(!b.full());
NISPS_EXPECT(b.capacity() == 8u);
}
NISPS_TEST(fixed_buffer_push_and_index) {
nisps::FixedBuffer<int, 4> b;
NISPS_EXPECT(b.push_back(10));
NISPS_EXPECT(b.push_back(20));
NISPS_EXPECT(b.push_back(30));
NISPS_EXPECT(b.size() == 3u);
NISPS_EXPECT(b[0] == 10);
NISPS_EXPECT(b[1] == 20);
NISPS_EXPECT(b[2] == 30);
NISPS_EXPECT(b.front() == 10);
NISPS_EXPECT(b.back() == 30);
}
NISPS_TEST(fixed_buffer_refuses_when_full) {
nisps::FixedBuffer<int, 2> b;
NISPS_EXPECT(b.push_back(1));
NISPS_EXPECT(b.push_back(2));
NISPS_EXPECT(b.full());
NISPS_EXPECT(!b.push_back(3)); // refused
NISPS_EXPECT(b.size() == 2u); // unchanged
}
NISPS_TEST(fixed_buffer_clear_resets) {
nisps::FixedBuffer<int, 4> b;
b.push_back(1); b.push_back(2);
b.clear();
NISPS_EXPECT(b.empty());
NISPS_EXPECT(b.push_back(99));
NISPS_EXPECT(b[0] == 99);
}
NISPS_TEST(fixed_buffer_iteration) {
nisps::FixedBuffer<int, 8> b;
for (int i = 0; i < 5; ++i) b.push_back(i * 2);
int sum = 0;
for (int v : b) sum += v;
NISPS_EXPECT(sum == 0 + 2 + 4 + 6 + 8);
}

100
tests/cpp/test_helpers.hpp Normal file
View file

@ -0,0 +1,100 @@
// tests/cpp/test_helpers.hpp — minimal assertion-based test harness.
//
// Why roll our own: zero external dependencies, builds in <1s, integrates
// trivially with CMake. Catch2/doctest would add a fetch step (≈ 10 MB and
// 30s of compile time) for what is currently a handful of asserts. If the
// suite grows beyond a few hundred lines we can revisit.
//
// API:
// NISPS_TEST(name) — declare a test
// NISPS_EXPECT(cond) — non-fatal check (records, keeps running)
// NISPS_ASSERT(cond) — fatal check (aborts the test)
// NISPS_EXPECT_NEAR(a, b, eps)
//
// Usage:
// NISPS_TEST(my_test) {
// NISPS_EXPECT(1 + 1 == 2);
// }
// int main() { return nisps::test::run_all(); }
#pragma once
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
namespace nisps::test {
struct TestFailure { const char* msg; };
struct TestCase {
const char* name;
void (*fn)();
};
inline std::vector<TestCase>& registry() {
static std::vector<TestCase> r;
return r;
}
struct Registrar {
Registrar(const char* name, void (*fn)()) {
registry().push_back({name, fn});
}
};
inline int run_all() {
int passed = 0, failed = 0;
for (const auto& tc : registry()) {
std::printf("[ RUN ] %s\n", tc.name);
try {
tc.fn();
std::printf("[ OK ] %s\n", tc.name);
++passed;
} catch (const TestFailure& f) {
std::printf("[ FAILED ] %s — %s\n", tc.name, f.msg);
++failed;
}
}
std::printf("\n[==========] %d passed, %d failed\n", passed, failed);
return failed == 0 ? 0 : 1;
}
} // namespace nisps::test
#define NISPS_TEST_CONCAT_(a, b) a##b
#define NISPS_TEST_CONCAT(a, b) NISPS_TEST_CONCAT_(a, b)
#define NISPS_TEST(name) \
static void NISPS_TEST_CONCAT(nisps_test_, name)(); \
static ::nisps::test::Registrar NISPS_TEST_CONCAT(nisps_test_reg_, name)( \
#name, &NISPS_TEST_CONCAT(nisps_test_, name)); \
static void NISPS_TEST_CONCAT(nisps_test_, name)()
#define NISPS_EXPECT(cond) \
do { \
if (!(cond)) { \
std::fprintf(stderr, \
" EXPECT failed at %s:%d: %s\n", \
__FILE__, __LINE__, #cond); \
throw ::nisps::test::TestFailure{#cond}; \
} \
} while (0)
#define NISPS_ASSERT(cond) NISPS_EXPECT(cond)
#define NISPS_EXPECT_NEAR(a, b, eps) \
do { \
const double da = static_cast<double>(a); \
const double db = static_cast<double>(b); \
if (std::fabs(da - db) > static_cast<double>(eps)) { \
std::fprintf(stderr, \
" EXPECT_NEAR failed at %s:%d: %s ≈ %s " \
"(|%g - %g| = %g > %g)\n", \
__FILE__, __LINE__, #a, #b, da, db, \
std::fabs(da - db), static_cast<double>(eps)); \
throw ::nisps::test::TestFailure{#a "" #b}; \
} \
} while (0)

8
tests/cpp/test_main.cpp Normal file
View file

@ -0,0 +1,8 @@
// tests/cpp/test_main.cpp — entry point that runs every test registered
// across the translation units linked into nisps_core_tests.
#include "test_helpers.hpp"
int main() {
return nisps::test::run_all();
}

57
tests/cpp/test_math.cpp Normal file
View file

@ -0,0 +1,57 @@
// tests/cpp/test_math.cpp — sanity check on clamping, sigmoid bounds, and
// curve catalog endpoint pinning.
#include "test_helpers.hpp"
#include "../../nisps/core/math.hpp"
NISPS_TEST(clamp01_bounds) {
NISPS_EXPECT(nisps::clamp01(-1.f) == 0.f);
NISPS_EXPECT(nisps::clamp01( 0.f) == 0.f);
NISPS_EXPECT(nisps::clamp01( 0.5f) == 0.5f);
NISPS_EXPECT(nisps::clamp01( 1.f) == 1.f);
NISPS_EXPECT(nisps::clamp01( 2.f) == 1.f);
}
NISPS_TEST(fast_sigmoid_in_unit_range) {
for (float x = -10.f; x <= 10.f; x += 0.5f) {
const float y = nisps::fast_sigmoid(x);
NISPS_EXPECT(y >= 0.f);
NISPS_EXPECT(y <= 1.f);
}
// Center pinned.
NISPS_EXPECT_NEAR(nisps::fast_sigmoid(0.f), 0.5f, 1e-6);
}
NISPS_TEST(fast_sigmoid_matches_exact_within_tolerance) {
// Document that fast_sigmoid is within ~2% of exact_sigmoid on [-6, 6].
for (float x = -6.f; x <= 6.f; x += 0.25f) {
const float fy = nisps::fast_sigmoid(x);
const float ey = nisps::exact_sigmoid(x);
NISPS_EXPECT_NEAR(fy, ey, 0.02);
}
}
NISPS_TEST(curve_endpoints_pinned) {
// Every curve must map 0→0 and 1→1 exactly.
using nisps::Curve;
Curve curves[] = {Curve::linear, Curve::exp, Curve::log, Curve::square,
Curve::sqrt, Curve::sigmoid, Curve::cubic};
for (Curve c : curves) {
NISPS_EXPECT_NEAR(nisps::apply_curve(c, 0.f), 0.0, 1e-5);
NISPS_EXPECT_NEAR(nisps::apply_curve(c, 1.f), 1.0, 1e-5);
}
}
NISPS_TEST(curve_monotone_increasing) {
using nisps::Curve;
Curve curves[] = {Curve::linear, Curve::exp, Curve::log, Curve::square,
Curve::sqrt, Curve::sigmoid, Curve::cubic};
for (Curve c : curves) {
float prev = nisps::apply_curve(c, 0.f);
for (float x = 0.05f; x <= 1.f + 1e-6f; x += 0.05f) {
const float y = nisps::apply_curve(c, x);
NISPS_EXPECT(y >= prev - 1e-6f); // non-decreasing
prev = y;
}
}
}

View file

@ -0,0 +1,64 @@
// tests/cpp/test_ring_buffer.cpp — single-threaded SPSC behavior. We don't
// stress the memory ordering in unit tests (that would need a multi-thread
// fuzz harness); we just confirm the FIFO invariants and the capacity guard.
#include "test_helpers.hpp"
#include "../../nisps/core/ring_buffer.hpp"
NISPS_TEST(ring_buffer_empty_pop_fails) {
nisps::RingBuffer<int, 4> r;
int v = -1;
NISPS_EXPECT(!r.try_pop(v));
NISPS_EXPECT(r.empty_approx());
}
NISPS_TEST(ring_buffer_fifo_order) {
nisps::RingBuffer<int, 4> r;
NISPS_EXPECT(r.try_push(1));
NISPS_EXPECT(r.try_push(2));
NISPS_EXPECT(r.try_push(3));
int v = 0;
NISPS_EXPECT(r.try_pop(v)); NISPS_EXPECT(v == 1);
NISPS_EXPECT(r.try_pop(v)); NISPS_EXPECT(v == 2);
NISPS_EXPECT(r.try_pop(v)); NISPS_EXPECT(v == 3);
NISPS_EXPECT(!r.try_pop(v));
}
NISPS_TEST(ring_buffer_full_push_fails) {
nisps::RingBuffer<int, 4> r;
NISPS_EXPECT(r.try_push(1));
NISPS_EXPECT(r.try_push(2));
NISPS_EXPECT(r.try_push(3));
NISPS_EXPECT(r.try_push(4));
NISPS_EXPECT(!r.try_push(5)); // full
NISPS_EXPECT(r.size_approx() == 4u);
}
NISPS_TEST(ring_buffer_wraparound) {
nisps::RingBuffer<int, 4> r;
int v = 0;
// Fill, drain, fill again — exercises index wrap.
for (int i = 0; i < 100; ++i) {
NISPS_EXPECT(r.try_push(i));
NISPS_EXPECT(r.try_pop(v));
NISPS_EXPECT(v == i);
}
NISPS_EXPECT(r.empty_approx());
}
NISPS_TEST(ring_buffer_partial_fill_drain) {
nisps::RingBuffer<int, 8> r;
for (int i = 0; i < 6; ++i) NISPS_EXPECT(r.try_push(i * 10));
NISPS_EXPECT(r.size_approx() == 6u);
int v = 0;
NISPS_EXPECT(r.try_pop(v)); NISPS_EXPECT(v == 0);
NISPS_EXPECT(r.try_pop(v)); NISPS_EXPECT(v == 10);
NISPS_EXPECT(r.try_push(60));
NISPS_EXPECT(r.try_push(70));
NISPS_EXPECT(r.size_approx() == 6u);
int expect[] = {20, 30, 40, 50, 60, 70};
for (int e : expect) {
NISPS_EXPECT(r.try_pop(v));
NISPS_EXPECT(v == e);
}
}

78
tests/cpp/test_rng.cpp Normal file
View file

@ -0,0 +1,78 @@
// tests/cpp/test_rng.cpp — verify deterministic seeding, range bounds, and
// reasonable statistical shape on the gaussian helper.
#include "test_helpers.hpp"
#include "../../nisps/core/rng.hpp"
NISPS_TEST(rng_deterministic_for_same_seed) {
nisps::Rng a(42ull);
nisps::Rng b(42ull);
for (int i = 0; i < 1000; ++i) {
NISPS_EXPECT(a.next_u64() == b.next_u64());
}
}
NISPS_TEST(rng_diverges_for_different_seeds) {
nisps::Rng a(0ull);
nisps::Rng b(1ull);
int distinct = 0;
for (int i = 0; i < 100; ++i) {
if (a.next_u64() != b.next_u64()) ++distinct;
}
// Should differ in nearly every draw (probability of collision ~ 2^-64).
NISPS_EXPECT(distinct >= 99);
}
NISPS_TEST(rng_zero_seed_does_not_lock_up) {
nisps::Rng r(0ull);
// Pure xoshiro with all-zero state is degenerate; our splitmix64 fan-out
// should prevent that. Confirm we get nonzero output.
bool any_nonzero = false;
for (int i = 0; i < 16; ++i) {
if (r.next_u64() != 0ull) { any_nonzero = true; break; }
}
NISPS_EXPECT(any_nonzero);
}
NISPS_TEST(rng_uniform_in_unit_interval) {
nisps::Rng r(7ull);
for (int i = 0; i < 10000; ++i) {
const float v = r.next_float_uniform();
NISPS_EXPECT(v >= 0.f);
NISPS_EXPECT(v < 1.f);
}
}
NISPS_TEST(rng_signed_in_minus_plus) {
nisps::Rng r(7ull);
for (int i = 0; i < 10000; ++i) {
const float v = r.next_float_signed();
NISPS_EXPECT(v >= -1.f);
NISPS_EXPECT(v < 1.f);
}
}
NISPS_TEST(rng_uniform_mean_near_half) {
nisps::Rng r(123ull);
double sum = 0.0;
constexpr int N = 100000;
for (int i = 0; i < N; ++i) sum += r.next_float_uniform();
const double mean = sum / N;
NISPS_EXPECT_NEAR(mean, 0.5, 0.01);
}
NISPS_TEST(rng_gaussian_stddev_close_to_one) {
nisps::Rng r(99ull);
constexpr int N = 100000;
double sum = 0.0, sq = 0.0;
for (int i = 0; i < N; ++i) {
const double v = r.next_float_gaussian(1.f);
sum += v;
sq += v * v;
}
const double mean = sum / N;
const double var = sq / N - mean * mean;
NISPS_EXPECT_NEAR(mean, 0.0, 0.05);
// sum-of-three-uniforms gives variance exactly 1.0 in the limit.
NISPS_EXPECT_NEAR(var, 1.0, 0.05);
}