diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8c540a..ea7cb31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,9 +58,31 @@ jobs: - name: Build run: cmake --build build + test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libavahi-compat-libdnssd-dev + + - name: Configure CMake + # BUILD_EXAMPLES=OFF so the test job configures only sendspin + tests and skips the + # example-only FetchContent deps (e.g. FTXUI). + run: cmake -B build-tests -DSENDSPIN_BUILD_TESTS=ON -DENABLE_SANITIZERS=ON -DBUILD_EXAMPLES=OFF . + + - name: Build tests + run: cmake --build build-tests --target sendspin_tests + + - name: Run tests + run: ctest --test-dir build-tests --output-on-failure + ci: name: CI - needs: [pre-commit, lint, build] + needs: [pre-commit, lint, build, test] runs-on: ubuntu-latest if: always() steps: diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f19a78..bd1962c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -184,4 +184,11 @@ else() message(STATUS "Skipping sendspin-cpp examples (BUILD_EXAMPLES=OFF)") endif() + # Unit tests (host only, opt-in via -DSENDSPIN_BUILD_TESTS=ON) + option(SENDSPIN_BUILD_TESTS "Build host unit tests" OFF) + if(SENDSPIN_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) + endif() + endif() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..46952e8 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,47 @@ +# Host unit tests for sendspin-cpp +# +# Enabled from the top-level CMakeLists.txt with -DSENDSPIN_BUILD_TESTS=ON. +# Tests link against the `sendspin` static library and exercise its internal +# logic directly (white-box: they include private headers from src/). + +include(FetchContent) + +# GoogleTest — fetched the same way as the library's other dependencies. +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.15.2 + GIT_SHALLOW TRUE +) +set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) +set(BUILD_GMOCK OFF CACHE BOOL "" FORCE) # only gtest_main is used; skip building gmock +FetchContent_MakeAvailable(googletest) + +# One executable aggregating every test_*.cpp file. gtest_discover_tests still +# registers the individual TEST() cases with CTest so failures are reported per case. +add_executable(sendspin_tests + test_audio_stream_info.cpp + test_time_filter.cpp + test_protocol.cpp +) + +# Reach the library's private headers (protocol_messages.h, time_filter.h, ...). +# The public include/ dir and ArduinoJson propagate transitively from `sendspin`. +# Use CMAKE_CURRENT_SOURCE_DIR (not CMAKE_SOURCE_DIR) so the path stays correct even +# if sendspin is ever added as a subdirectory of a larger superproject. +target_include_directories(sendspin_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src) + +target_link_libraries(sendspin_tests PRIVATE sendspin GTest::gtest_main) + +target_compile_features(sendspin_tests PRIVATE cxx_std_20) +target_compile_options(sendspin_tests PRIVATE -Wall -Wextra) + +# Match the library's sanitizer configuration so the test binary is instrumented too. +if(ENABLE_SANITIZERS) + target_compile_options(sendspin_tests PRIVATE + -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(sendspin_tests PRIVATE -fsanitize=address,undefined) +endif() + +include(GoogleTest) +gtest_discover_tests(sendspin_tests) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..4381011 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,48 @@ +# Unit tests + +Host-only unit tests for the cross-platform logic in `src/`. They link against the `sendspin` +static library and run on macOS/Linux with [GoogleTest](https://github.com/google/googletest) +(fetched automatically via CMake `FetchContent`). + +## Running + +From the repository root: + +```bash +cmake -B build-tests -DSENDSPIN_BUILD_TESTS=ON . +cmake --build build-tests --target sendspin_tests +ctest --test-dir build-tests --output-on-failure +``` + +Run the test binary directly to use GoogleTest filters: + +```bash +./build-tests/tests/sendspin_tests --gtest_filter='Protocol.*' +``` + +## Running under sanitizers + +Add `-DENABLE_SANITIZERS=ON` to build with AddressSanitizer and UndefinedBehaviorSanitizer. This +is what CI runs, and it is the recommended way to exercise the pointer-heavy code (the message +formatter, JSON parsing): + +```bash +cmake -B build-tests-asan -DSENDSPIN_BUILD_TESTS=ON -DENABLE_SANITIZERS=ON . +cmake --build build-tests-asan --target sendspin_tests +ctest --test-dir build-tests-asan --output-on-failure +``` + +## Layout + +Each `test_*.cpp` file covers one unit of cross-platform logic: + +- `test_protocol.cpp` — wire-protocol parsing/formatting: enum round-trips, message dispatch, the + tri-state metadata/color deltas, and the hand-rolled `client/time` formatter checked against + `snprintf`. +- `test_time_filter.cpp` — `SendspinTimeFilter` invariants (monotonic-timestamp rejection, reset, + offset round-trip, convergence). +- `test_audio_stream_info.cpp` — byte/frame/sample/duration conversions. + +These are white-box tests: they include private headers from `src/`, so the test target adds +`src/` to its include path. To add a new test file, create `test_.cpp` here and add it to +the `add_executable(sendspin_tests ...)` list in `tests/CMakeLists.txt`. diff --git a/tests/test_audio_stream_info.cpp b/tests/test_audio_stream_info.cpp new file mode 100644 index 0000000..0b737fd --- /dev/null +++ b/tests/test_audio_stream_info.cpp @@ -0,0 +1,83 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Unit tests for AudioStreamInfo's byte/frame/sample/duration conversions. These are exact +// integer arithmetic, so the assertions are exact: a wrong conversion silently corrupts +// buffering and A/V sync, which is exactly the kind of bug worth pinning down. + +#include "audio_stream_info.h" +#include + +using sendspin::AudioStreamInfo; + +// 16-bit stereo at 44.1 kHz: 2 bytes/sample, 4 bytes/frame. +TEST(AudioStreamInfo, CdQualityConversions) { + const AudioStreamInfo info(16, 2, 44100); + + EXPECT_EQ(info.get_bits_per_sample(), 16); + EXPECT_EQ(info.get_channels(), 2); + EXPECT_EQ(info.get_sample_rate(), 44100u); + + EXPECT_EQ(info.frames_to_bytes(100), 400u); + EXPECT_EQ(info.bytes_to_frames(400), 100u); + EXPECT_EQ(info.samples_to_bytes(8), 16u); + + // 1 second of audio == sample_rate frames == sample_rate * frame_size bytes. + EXPECT_EQ(info.ms_to_bytes(1000), 176400u); + EXPECT_EQ(info.bytes_to_ms(176400), 1000u); + + EXPECT_EQ(info.frames_to_microseconds(441), 10000); // 10 ms + EXPECT_EQ(info.frames_to_microseconds(2205), 50000); // 50 ms +} + +// frames_to_microseconds() widens to 64-bit internally, so it stays exact well past the point +// where a 32-bit (frames * 1e6) product overflows (~4295 frames, ~97 ms at 44.1 kHz). These +// counts would have silently wrapped under the old implementation. +TEST(AudioStreamInfo, FramesToMicrosecondsNoOverflowAtLargeCounts) { + const AudioStreamInfo info(16, 2, 44100); + + EXPECT_EQ(info.frames_to_microseconds(44100), 1000000); // exactly 1 s + EXPECT_EQ(info.frames_to_microseconds(88200), 2000000); // exactly 2 s + EXPECT_EQ(info.frames_to_microseconds(4410000), 100000000); // 100 s, far past the old wrap +} + +// 16-bit mono at 8 kHz, the library's default-ish low-rate case. +TEST(AudioStreamInfo, MonoLowRateConversions) { + const AudioStreamInfo info(16, 1, 8000); + + EXPECT_EQ(info.ms_to_bytes(125), 2000u); // 125 ms * 16000 B/s + EXPECT_EQ(info.bytes_to_ms(2000), 125u); + EXPECT_EQ(info.frames_to_microseconds(800), 100000); // 100 ms +} + +// Bit depths that are not multiples of 8 round up to whole bytes per sample. +TEST(AudioStreamInfo, BytesPerSampleRounding) { + EXPECT_EQ(AudioStreamInfo(8, 1, 48000).frames_to_bytes(10), 10u); // 1 byte/sample + EXPECT_EQ(AudioStreamInfo(24, 2, 48000).frames_to_bytes(10), 60u); // 3 bytes/sample, stereo +} + +TEST(AudioStreamInfo, Equality) { + const AudioStreamInfo a(16, 2, 44100); + EXPECT_TRUE(a == AudioStreamInfo(16, 2, 44100)); + EXPECT_TRUE(a != AudioStreamInfo(16, 2, 48000)); // sample rate differs + EXPECT_TRUE(a != AudioStreamInfo(24, 2, 44100)); // bit depth differs + EXPECT_TRUE(a != AudioStreamInfo(16, 1, 44100)); // channel count differs +} + +TEST(AudioStreamInfo, DefaultConstruction) { + const AudioStreamInfo info; + EXPECT_EQ(info.get_bits_per_sample(), 16); + EXPECT_EQ(info.get_channels(), 1); + EXPECT_EQ(info.get_sample_rate(), sendspin::DEFAULT_SAMPLE_RATE_HZ); +} diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp new file mode 100644 index 0000000..79501a5 --- /dev/null +++ b/tests/test_protocol.cpp @@ -0,0 +1,310 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Unit tests for the wire-protocol parsing/formatting in protocol.cpp. This is the highest-value +// surface to test: lots of subtle branching (tri-state optional deltas, range validation, +// malformed-input handling) where a bug is silent rather than a crash, plus a hand-rolled int64 +// formatter that we can check against snprintf as a free correctness oracle. + +#include "protocol_messages.h" +#include +#include + +#include +#include +#include +#include +#include + +using namespace sendspin; // NOLINT(google-build-using-namespace) -- test-local convenience + +namespace { + +// Parses a JSON string and returns the root object via the out-parameter, keeping the backing +// document alive in the caller. Returns false if the JSON is malformed. +bool parse(const std::string& json, JsonDocument& doc, JsonObject& root) { + if (deserializeJson(doc, json)) { + return false; + } + root = doc.as(); + return true; +} + +} // namespace + +// ============================================================================ +// Enum <-> wire-string round-trips +// ============================================================================ + +TEST(Protocol, CodecRoundTrip) { + EXPECT_EQ(codec_format_from_string("flac"), SendspinCodecFormat::FLAC); + EXPECT_EQ(codec_format_from_string("opus"), SendspinCodecFormat::OPUS); + EXPECT_EQ(codec_format_from_string("pcm"), SendspinCodecFormat::PCM); + EXPECT_STREQ(to_cstr(SendspinCodecFormat::FLAC), "flac"); + EXPECT_FALSE(codec_format_from_string("mp3").has_value()); // unknown -> nullopt +} + +// Every controller command must survive a to_cstr -> from_string round-trip. Catches typos in the +// wire strings that would otherwise silently drop a command. +TEST(Protocol, ControllerCommandRoundTrip) { + const SendspinControllerCommand commands[] = { + SendspinControllerCommand::PLAY, SendspinControllerCommand::PAUSE, + SendspinControllerCommand::STOP, SendspinControllerCommand::NEXT, + SendspinControllerCommand::PREVIOUS, SendspinControllerCommand::VOLUME, + SendspinControllerCommand::MUTE, SendspinControllerCommand::REPEAT_OFF, + SendspinControllerCommand::REPEAT_ONE, SendspinControllerCommand::REPEAT_ALL, + SendspinControllerCommand::SHUFFLE, SendspinControllerCommand::UNSHUFFLE, + SendspinControllerCommand::SWITCH, + }; + for (const auto cmd : commands) { + const auto parsed = controller_command_from_string(to_cstr(cmd)); + ASSERT_TRUE(parsed.has_value()) << "no round-trip for " << to_cstr(cmd); + EXPECT_EQ(parsed.value(), cmd); + } + EXPECT_FALSE(controller_command_from_string("not_a_command").has_value()); +} + +// ============================================================================ +// Message-type dispatch +// ============================================================================ + +TEST(Protocol, DetermineMessageType) { + JsonDocument doc; + JsonObject root; + + ASSERT_TRUE(parse(R"({"type":"server/hello"})", doc, root)); + EXPECT_EQ(determine_message_type(root), SendspinServerToClientMessageType::SERVER_HELLO); + + ASSERT_TRUE(parse(R"({"type":"stream/clear"})", doc, root)); + EXPECT_EQ(determine_message_type(root), SendspinServerToClientMessageType::STREAM_CLEAR); + + ASSERT_TRUE(parse(R"({"type":"made/up"})", doc, root)); + EXPECT_EQ(determine_message_type(root), SendspinServerToClientMessageType::UNKNOWN); + + ASSERT_TRUE(parse(R"({})", doc, root)); // missing type field + EXPECT_EQ(determine_message_type(root), SendspinServerToClientMessageType::UNKNOWN); +} + +// ============================================================================ +// server/time NTP-style offset computation +// ============================================================================ + +TEST(Protocol, ServerTimeOffsetAndError) { + JsonDocument doc; + JsonObject root; + ASSERT_TRUE(parse(R"({"payload":{"client_transmitted":1000,)" + R"("server_received":1500,"server_transmitted":1600}})", + doc, root)); + + int64_t offset = 0; + int64_t max_error = 0; + const int64_t client_received = 2000; + ASSERT_TRUE(process_server_time_message(root, client_received, &offset, &max_error)); + + // offset = ((T2-T1) + (T3-T4)) / 2 = ((1500-1000) + (1600-2000)) / 2 = 50 + EXPECT_EQ(offset, 50); + // max_error = ((T4-T1) - (T3-T2)) / 2 = ((2000-1000) - (1600-1500)) / 2 = 450 + EXPECT_EQ(max_error, 450); +} + +TEST(Protocol, ServerTimeRejectsMissingFields) { + JsonDocument doc; + JsonObject root; + ASSERT_TRUE(parse(R"({"payload":{"client_transmitted":1000}})", doc, root)); + + int64_t offset = 0; + int64_t max_error = 0; + EXPECT_FALSE(process_server_time_message(root, 2000, &offset, &max_error)); +} + +// ============================================================================ +// Metadata tri-state delta parse + merge +// +// Each field is std::optional>: +// absent on the wire -> outer nullopt -> merge leaves the field alone +// explicit JSON null -> outer engaged, inner nullopt -> merge clears the field +// value -> outer + inner engaged -> merge overwrites +// ============================================================================ + +TEST(Protocol, MetadataValueUpdate) { + JsonDocument doc; + JsonObject root; + ASSERT_TRUE(parse(R"({"type":"server/state","payload":{"metadata":)" + R"({"timestamp":123,"title":"Song","artist":"Band"}}})", + doc, root)); + + ServerStateMessage msg; + ASSERT_TRUE(process_server_state_message(root, &msg)); + ASSERT_TRUE(msg.metadata.has_value()); + + ServerMetadataStateObject current; + apply_metadata_state_deltas(¤t, msg.metadata.value()); + + EXPECT_EQ(current.timestamp, 123); + ASSERT_TRUE(current.title.has_value()); + EXPECT_EQ(current.title.value(), "Song"); + ASSERT_TRUE(current.artist.has_value()); + EXPECT_EQ(current.artist.value(), "Band"); +} + +TEST(Protocol, MetadataNullClearsAndAbsentPreserves) { + // Start with both fields already populated. + ServerMetadataStateObject current; + current.title = "Song"; + current.artist = "Band"; + + // Delta sets title to null (clear) and omits artist (preserve). + JsonDocument doc; + JsonObject root; + ASSERT_TRUE(parse(R"({"type":"server/state","payload":{"metadata":)" + R"({"timestamp":200,"title":null}}})", + doc, root)); + + ServerStateMessage msg; + ASSERT_TRUE(process_server_state_message(root, &msg)); + ASSERT_TRUE(msg.metadata.has_value()); + apply_metadata_state_deltas(¤t, msg.metadata.value()); + + EXPECT_FALSE(current.title.has_value()); // explicit null cleared it + ASSERT_TRUE(current.artist.has_value()); // absent left it untouched + EXPECT_EQ(current.artist.value(), "Band"); +} + +TEST(Protocol, MetadataMissingTimestampIsRejected) { + JsonDocument doc; + JsonObject root; + ASSERT_TRUE( + parse(R"({"type":"server/state","payload":{"metadata":{"title":"X"}}})", doc, root)); + + ServerStateMessage msg; + // The top-level call still succeeds, but the malformed metadata sub-object is dropped. + ASSERT_TRUE(process_server_state_message(root, &msg)); + EXPECT_FALSE(msg.metadata.has_value()); +} + +// ============================================================================ +// Color parsing: range validation + tri-state merge +// ============================================================================ + +TEST(Protocol, ColorRangeValidationAndMerge) { + // Pre-populate accent and on_dark so we can observe "preserve" vs "clear". + ServerColorStateObject current; + current.accent = RgbColor{1, 2, 3}; + current.on_dark = RgbColor{9, 9, 9}; + + JsonDocument doc; + JsonObject root; + ASSERT_TRUE(parse(R"({"type":"server/state","payload":{"color":{"timestamp":7,)" + R"("primary":[10,20,30],"accent":[300,0,0],"on_dark":null}}})", + doc, root)); + + ServerStateMessage msg; + ASSERT_TRUE(process_server_state_message(root, &msg)); + ASSERT_TRUE(msg.color.has_value()); + apply_color_state_deltas(¤t, msg.color.value()); + + ASSERT_TRUE(current.primary.has_value()); + EXPECT_EQ(current.primary.value(), (RgbColor{10, 20, 30})); + + // accent had an out-of-range component (300) -> treated as absent -> preserved. + ASSERT_TRUE(current.accent.has_value()); + EXPECT_EQ(current.accent.value(), (RgbColor{1, 2, 3})); + + // on_dark was explicit null -> cleared. + EXPECT_FALSE(current.on_dark.has_value()); +} + +// ============================================================================ +// format_client_time_message: hand-rolled int64 formatter checked against snprintf +// ============================================================================ + +// snprintf is the reference implementation; the hand-rolled formatter must match it byte-for-byte. +static std::string reference_time_message(int64_t v) { + char buf[128]; + std::snprintf(buf, sizeof(buf), + R"({"type":"client/time","payload":{"client_transmitted":%lld}})", + static_cast(v)); + return std::string(buf); +} + +TEST(Protocol, FormatTimeMessageMatchesSnprintf) { + const int64_t edge_cases[] = {0, 1, -1, 9, 10, 99, + 100, 12345, -12345, INT64_MAX, INT64_MIN, INT64_MIN + 1}; + char buf[TIME_MESSAGE_BUF_SIZE]; + for (const int64_t v : edge_cases) { + const size_t n = format_client_time_message(buf, sizeof(buf), v); + ASSERT_GT(n, 0u) << "v=" << v; + EXPECT_EQ(std::string(buf, n), reference_time_message(v)) << "v=" << v; + } +} + +// Property/fuzz style: thousands of pseudo-random int64s, all checked against the oracle. Catches +// off-by-one digit-count bugs in the clz-based formatter that fixed cases might miss. +TEST(Protocol, FormatTimeMessageFuzzAgainstSnprintf) { + std::mt19937_64 rng(0xC0FFEE); // fixed seed -> deterministic, reproducible failures + std::uniform_int_distribution dist(INT64_MIN, INT64_MAX); + + char buf[TIME_MESSAGE_BUF_SIZE]; + for (int i = 0; i < 20000; ++i) { + const int64_t v = dist(rng); + const size_t n = format_client_time_message(buf, sizeof(buf), v); + ASSERT_GT(n, 0u) << "v=" << v; + ASSERT_EQ(std::string(buf, n), reference_time_message(v)) << "v=" << v; + } +} + +TEST(Protocol, FormatTimeMessageRejectsTooSmallBuffer) { + char buf[10]; + EXPECT_EQ(format_client_time_message(buf, sizeof(buf), 123), 0u); +} + +// ============================================================================ +// Outgoing message formatting (round-trip through the parser) +// ============================================================================ + +TEST(Protocol, FormatClientCommandVolume) { + const std::string out = format_client_command_message(SendspinControllerCommand::VOLUME, 50); + + JsonDocument doc; + ASSERT_FALSE(deserializeJson(doc, out)); + EXPECT_STREQ(doc["type"], "client/command"); + EXPECT_STREQ(doc["payload"]["controller"]["command"], "volume"); + EXPECT_EQ(doc["payload"]["controller"]["volume"].as(), 50); + // The mute payload belongs to a different command and must not leak in. + EXPECT_FALSE(doc["payload"]["controller"]["mute"].is()); +} + +// MUTE carries a boolean payload (a separate branch from VOLUME's uint8_t). +TEST(Protocol, FormatClientCommandMute) { + const std::string out = + format_client_command_message(SendspinControllerCommand::MUTE, std::nullopt, true); + + JsonDocument doc; + ASSERT_FALSE(deserializeJson(doc, out)); + EXPECT_STREQ(doc["payload"]["controller"]["command"], "mute"); + ASSERT_TRUE(doc["payload"]["controller"]["mute"].is()); + EXPECT_TRUE(doc["payload"]["controller"]["mute"].as()); + EXPECT_FALSE(doc["payload"]["controller"]["volume"].is()); +} + +// A no-argument command (PLAY) emits just the command, with neither payload field present. +TEST(Protocol, FormatClientCommandNoArgs) { + const std::string out = format_client_command_message(SendspinControllerCommand::PLAY); + + JsonDocument doc; + ASSERT_FALSE(deserializeJson(doc, out)); + EXPECT_STREQ(doc["payload"]["controller"]["command"], "play"); + EXPECT_FALSE(doc["payload"]["controller"]["volume"].is()); + EXPECT_FALSE(doc["payload"]["controller"]["mute"].is()); +} diff --git a/tests/test_time_filter.cpp b/tests/test_time_filter.cpp new file mode 100644 index 0000000..abdfa55 --- /dev/null +++ b/tests/test_time_filter.cpp @@ -0,0 +1,92 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Unit tests for the SendspinTimeFilter Kalman filter. We don't try to prove the filter is +// "optimal" -- instead we pin down the behavioral invariants a refactor could silently break: +// monotonic-timestamp rejection, reset semantics, the offset/inverse round-trip, and convergence +// toward a constant offset. + +#include "time_filter.h" + +#include + +#include + +using sendspin::SendspinTimeFilter; + +TEST(TimeFilter, HasUpdateStartsFalse) { + SendspinTimeFilter filter; + EXPECT_FALSE(filter.has_update()); + + filter.update(/*measurement=*/1000, /*max_error=*/100, /*time_added=*/5000); + EXPECT_TRUE(filter.has_update()); +} + +// The first measurement establishes the offset baseline, so server_time = client_time + offset. +TEST(TimeFilter, FirstSampleEstablishesOffset) { + SendspinTimeFilter filter; + filter.update(/*measurement=*/1000, /*max_error=*/100, /*time_added=*/5000); + + EXPECT_EQ(filter.compute_server_time(5000), 6000); // 5000 + 1000 offset + EXPECT_EQ(filter.compute_client_time(6000), 5000); // exact inverse with no drift +} + +// time_added <= last_update_ is rejected: it guards against divide-by-zero and out-of-order +// packets. A rejected update must leave the estimate untouched. +TEST(TimeFilter, RejectsNonMonotonicTimestamps) { + SendspinTimeFilter filter; + filter.update(/*measurement=*/1000, /*max_error=*/100, /*time_added=*/5000); + const int64_t before = filter.compute_server_time(5000); + + // Same timestamp -> skipped; a wild measurement must not move the estimate. + filter.update(/*measurement=*/999999, /*max_error=*/100, /*time_added=*/5000); + EXPECT_EQ(filter.compute_server_time(5000), before); + + // Earlier timestamp -> also skipped. + filter.update(/*measurement=*/-999999, /*max_error=*/100, /*time_added=*/4000); + EXPECT_EQ(filter.compute_server_time(5000), before); +} + +TEST(TimeFilter, ResetClearsState) { + SendspinTimeFilter filter; + filter.update(1000, 100, 5000); + filter.update(1000, 100, 6000); + ASSERT_TRUE(filter.has_update()); + + filter.reset(); + EXPECT_FALSE(filter.has_update()); +} + +// Fed a constant true offset with low measurement noise, the filter should settle on that offset +// and report shrinking uncertainty. +TEST(TimeFilter, ConvergesToConstantOffset) { + SendspinTimeFilter filter; + + constexpr int64_t kTrueOffset = 5000; + constexpr int64_t kMaxError = 100; // measurement std dev = max_error * 0.5 = 50 + + for (int i = 1; i <= 100; ++i) { + const int64_t t = static_cast(i) * 100000; // 100 ms apart + filter.update(kTrueOffset, kMaxError, t); + } + + const int64_t t = 100 * 100000; + const int64_t estimated_offset = filter.compute_server_time(t) - t; + EXPECT_NEAR(static_cast(estimated_offset), static_cast(kTrueOffset), 5.0); + + // Uncertainty should have collapsed well below the single-sample measurement std dev. + const int64_t error = filter.get_error(); + EXPECT_GT(error, 0); + EXPECT_LT(error, 50); +}