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:
parent
4f60fc8405
commit
e5bf2aa055
7 changed files with 427 additions and 0 deletions
68
nisps/CMakeLists.txt
Normal file
68
nisps/CMakeLists.txt
Normal 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()
|
||||
52
tests/cpp/test_fixed_buffer.cpp
Normal file
52
tests/cpp/test_fixed_buffer.cpp
Normal 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
100
tests/cpp/test_helpers.hpp
Normal 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
8
tests/cpp/test_main.cpp
Normal 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
57
tests/cpp/test_math.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
64
tests/cpp/test_ring_buffer.cpp
Normal file
64
tests/cpp/test_ring_buffer.cpp
Normal 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
78
tests/cpp/test_rng.cpp
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue