Files
kxkmandClaude Sonnet 4.6 defc5eba2d style: clang-format-18 all firmware sources (full sweep)
Previous commit only formatted 3 files; CI checks all 27 files
in src/, include/, test/. Format the full set to clear firmware-lint.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-27 05:46:03 +01:00

765 lines
25 KiB
C++

/// @file test_modules.cpp
/// Unity tests for VoiceController state-machine logic and OtaManager version
/// checking. Compiles on the native (Linux x86_64) target with -D UNIT_TEST=1.
/// No Arduino/ESP32 headers are used.
#include <cstring>
#include <string>
#include <unity.h>
#include <utility>
#include <vector>
// ---------------------------------------------------------------------------
// Provide a yield() stub so VoiceController implementation can call it.
// On ESP32 this comes from Arduino.h; on native it is a no-op.
// ---------------------------------------------------------------------------
inline void yield() {}
// Include the pure-C++ headers (no Arduino deps).
#include "../../include/firmware_utils.h"
#include "../../include/ota_manager.h"
#include "../../include/voice_controller.h"
// ===========================================================================
// VoiceController implementation (copied from voice_controller.cpp to avoid
// pulling in Arduino.h which is not available on native).
// ===========================================================================
VoiceController::VoiceController(std::string device_id, BackendClient &backend,
MediaController &media, UiRenderer &ui)
: device_id_(std::move(device_id)), backend_(backend), media_(media), ui_(ui) {}
void VoiceController::Boot() {
phase_ = VoicePhase::kIdle;
RenderState("pret", IdleSummary(media_.Snapshot()), false);
backend_.SendPlayerEvent(device_id_, "boot", media_.Snapshot(), "voice-controller-ready");
}
bool VoiceController::BeginPushToTalk() {
if (phase_ != VoicePhase::kIdle) {
return false;
}
phase_ = VoicePhase::kRecording;
RenderState("j'ecoute", "Maintiens le bouton pour parler.", true);
return true;
}
bool VoiceController::CompletePushToTalk(const std::vector<uint8_t> &wav_data) {
if (phase_ != VoicePhase::kRecording) {
return false;
}
if (wav_data.empty()) {
return Fail("capture audio vide");
}
phase_ = VoicePhase::kThinking;
RenderState("je reflechis", "Envoi de la requete a Mascarade.", true);
last_response_ = backend_.SubmitVoiceSession(device_id_, media_.Snapshot(), wav_data);
yield();
if (!last_response_.ok) {
const std::string error_text =
last_response_.error.empty() ? "session vocale en echec" : last_response_.error;
return Fail(error_text);
}
const MediaSnapshot before = media_.Snapshot();
media_.ApplyIntent(last_response_.intent);
const MediaSnapshot after = media_.Snapshot();
if (ShouldPublishPlaybackStarted(before, after, last_response_.intent)) {
const std::string detail = !after.station.empty() ? after.station : after.track;
backend_.SendPlayerEvent(device_id_, "playback_started", after, detail);
}
media_.PrepareForReply(last_response_.player_action);
phase_ = VoicePhase::kSpeaking;
RenderState("je reponds",
last_response_.reply_text.empty() ? "Reponse audio en preparation."
: last_response_.reply_text,
true);
if (!last_response_.reply_audio_url.empty()) {
std::vector<uint8_t> reply_audio;
if (backend_.DownloadReplyAudio(last_response_.reply_audio_url, &reply_audio)) {
yield();
if (!media_.PlayReplyAudio(reply_audio)) {
backend_.SendPlayerEvent(device_id_, "playback_failed", media_.Snapshot(),
"tts reply playback failed");
}
} else {
backend_.SendPlayerEvent(device_id_, "playback_failed", media_.Snapshot(),
"tts reply download failed");
}
}
media_.RestoreAfterReply(last_response_.intent.resume_media_after_tts);
phase_ = VoicePhase::kIdle;
RenderState("pret", IdleSummary(media_.Snapshot()), false);
return true;
}
bool VoiceController::Fail(const std::string &error_text) {
phase_ = VoicePhase::kError;
last_response_.ok = false;
last_response_.error = error_text;
RenderState("erreur reseau", error_text, false);
backend_.SendPlayerEvent(device_id_, "voice_error", media_.Snapshot(), error_text);
media_.RestoreAfterReply(true);
phase_ = VoicePhase::kIdle;
RenderState("pret", IdleSummary(media_.Snapshot()), false);
return false;
}
void VoiceController::RenderState(const std::string &headline, const std::string &summary,
bool show_ring) {
ui_.Render(media_.Snapshot(), phase_, headline, summary, show_ring);
}
std::string VoiceController::IdleSummary(const MediaSnapshot &media) {
return FwIdleSummary(media);
}
bool VoiceController::ShouldPublishPlaybackStarted(const MediaSnapshot &before,
const MediaSnapshot &after,
const VoiceIntent &intent) {
return FwShouldPublishPlaybackStarted(before, after, intent);
}
// ===========================================================================
// OtaManager constructor stub (the real ota_manager.cpp needs Arduino/HTTP).
// We only need the constructor and simple accessors for testing.
// ===========================================================================
OtaManager::OtaManager(const std::string &current_version) : current_version_(current_version) {}
// Stubs for methods that depend on Arduino/HTTP — not called in our tests
// but needed to satisfy the linker (non-pure-virtual members).
OtaCheckResult OtaManager::CheckOnly() {
return FetchLatestInfo();
}
OtaCheckResult OtaManager::CheckAndUpdate() {
OtaCheckResult info = FetchLatestInfo();
if (info.status == OtaCheckResult::Status::kCheckFailed)
return info;
if (info.status == OtaCheckResult::Status::kUpToDate)
return info;
if (FlashFromUrl(info.url)) {
info.status = OtaCheckResult::Status::kFlashOk;
} else {
info.status = OtaCheckResult::Status::kFlashFailed;
info.error = "flash error";
}
return info;
}
OtaCheckResult OtaManager::FetchLatestInfo() {
OtaCheckResult result;
result.status = OtaCheckResult::Status::kCheckFailed;
result.error = "not available on native";
return result;
}
bool OtaManager::FlashFromUrl(const std::string & /*url*/) {
return false;
}
// ===========================================================================
// Mock implementations of abstract interfaces
// ===========================================================================
class MockBackend : public BackendClient {
public:
VoiceSessionResponse next_voice_response;
bool download_succeeds = true;
std::vector<uint8_t> download_audio;
int send_event_count = 0;
std::string last_event_name;
std::string last_event_detail;
int submit_voice_count = 0;
int download_count = 0;
bool SendPlayerEvent(const std::string & /*device_id*/, const std::string &event_name,
const MediaSnapshot & /*media*/, const std::string &detail) override {
++send_event_count;
last_event_name = event_name;
last_event_detail = detail;
return true;
}
VoiceSessionResponse SubmitVoiceSession(const std::string & /*device_id*/,
const MediaSnapshot & /*media*/,
const std::vector<uint8_t> & /*wav_data*/) override {
++submit_voice_count;
return next_voice_response;
}
bool DownloadReplyAudio(const std::string & /*audio_url*/,
std::vector<uint8_t> *wav_data) override {
++download_count;
if (download_succeeds && wav_data) {
*wav_data = download_audio;
}
return download_succeeds;
}
};
class MockMedia : public MediaController {
public:
MediaSnapshot snapshot;
int apply_intent_count = 0;
int prepare_count = 0;
int restore_count = 0;
int play_reply_count = 0;
bool play_reply_result = true;
VoiceIntent last_intent;
PlayerAction last_prepare_action = PlayerAction::kNone;
bool last_restore_resume = false;
MediaSnapshot Snapshot() const override { return snapshot; }
void ApplyIntent(const VoiceIntent &intent) override {
++apply_intent_count;
last_intent = intent;
}
void PrepareForReply(PlayerAction action) override {
++prepare_count;
last_prepare_action = action;
}
void RestoreAfterReply(bool resume_media_after_tts) override {
++restore_count;
last_restore_resume = resume_media_after_tts;
}
bool PlayReplyAudio(const std::vector<uint8_t> & /*wav_data*/) override {
++play_reply_count;
return play_reply_result;
}
};
class MockUi : public UiRenderer {
public:
int render_count = 0;
VoicePhase last_phase = VoicePhase::kIdle;
std::string last_headline;
std::string last_summary;
bool last_show_ring = false;
void Render(const MediaSnapshot & /*media*/, VoicePhase phase, const std::string &headline,
const std::string &summary, bool show_ring) override {
++render_count;
last_phase = phase;
last_headline = headline;
last_summary = summary;
last_show_ring = show_ring;
}
};
// ===========================================================================
// Helper: create a dummy WAV buffer (non-empty)
// ===========================================================================
static std::vector<uint8_t> DummyWav() {
return {'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'A', 'V', 'E'};
}
// ===========================================================================
// VoiceController: state transition tests
// ===========================================================================
void test_vc_initial_phase_is_idle() {
MockBackend backend;
MockMedia media;
MockUi ui;
VoiceController vc("dev-001", backend, media, ui);
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
}
void test_vc_boot_sends_event_and_stays_idle() {
MockBackend backend;
MockMedia media;
MockUi ui;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
TEST_ASSERT_EQUAL_INT(1, backend.send_event_count);
TEST_ASSERT_EQUAL_STRING("boot", backend.last_event_name.c_str());
TEST_ASSERT_TRUE(ui.render_count > 0);
}
void test_vc_begin_ptt_transitions_to_recording() {
MockBackend backend;
MockMedia media;
MockUi ui;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
bool ok = vc.BeginPushToTalk();
TEST_ASSERT_TRUE(ok);
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording), static_cast<int>(vc.phase()));
}
void test_vc_begin_ptt_fails_when_not_idle() {
MockBackend backend;
MockMedia media;
MockUi ui;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
bool ok = vc.BeginPushToTalk();
TEST_ASSERT_FALSE(ok);
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording), static_cast<int>(vc.phase()));
}
void test_vc_complete_ptt_fails_when_not_recording() {
MockBackend backend;
MockMedia media;
MockUi ui;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
bool ok = vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_FALSE(ok);
TEST_ASSERT_EQUAL_INT(0, backend.submit_voice_count);
}
void test_vc_complete_ptt_empty_audio_fails() {
MockBackend backend;
MockMedia media;
MockUi ui;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
std::vector<uint8_t> empty;
bool ok = vc.CompletePushToTalk(empty);
TEST_ASSERT_FALSE(ok);
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
TEST_ASSERT_EQUAL_STRING("capture audio vide", vc.last_response().error.c_str());
}
void test_vc_full_successful_flow_idle_to_idle() {
MockBackend backend;
MockMedia media;
MockUi ui;
backend.next_voice_response.ok = true;
backend.next_voice_response.reply_text = "Voici la radio FIP";
backend.next_voice_response.reply_audio_url = "";
backend.next_voice_response.intent.type = "play";
backend.next_voice_response.player_action = PlayerAction::kDuck;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
TEST_ASSERT_TRUE(vc.BeginPushToTalk());
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording), static_cast<int>(vc.phase()));
bool ok = vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_TRUE(ok);
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
TEST_ASSERT_EQUAL_INT(1, backend.submit_voice_count);
TEST_ASSERT_EQUAL_INT(1, media.apply_intent_count);
TEST_ASSERT_EQUAL_INT(1, media.prepare_count);
TEST_ASSERT_EQUAL_INT(1, media.restore_count);
}
void test_vc_successful_flow_with_audio_download() {
MockBackend backend;
MockMedia media;
MockUi ui;
backend.next_voice_response.ok = true;
backend.next_voice_response.reply_text = "Playing now";
backend.next_voice_response.reply_audio_url = "http://example.com/reply.wav";
backend.next_voice_response.player_action = PlayerAction::kStopResume;
backend.download_succeeds = true;
backend.download_audio = DummyWav();
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
bool ok = vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_TRUE(ok);
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
TEST_ASSERT_EQUAL_INT(1, backend.download_count);
TEST_ASSERT_EQUAL_INT(1, media.play_reply_count);
}
void test_vc_backend_error_returns_to_idle() {
MockBackend backend;
MockMedia media;
MockUi ui;
backend.next_voice_response.ok = false;
backend.next_voice_response.error = "server timeout";
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
bool ok = vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_FALSE(ok);
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
TEST_ASSERT_EQUAL_STRING("server timeout", vc.last_response().error.c_str());
TEST_ASSERT_EQUAL_STRING("voice_error", backend.last_event_name.c_str());
}
void test_vc_backend_error_empty_message_uses_default() {
MockBackend backend;
MockMedia media;
MockUi ui;
backend.next_voice_response.ok = false;
backend.next_voice_response.error = "";
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_EQUAL_STRING("session vocale en echec", vc.last_response().error.c_str());
}
void test_vc_audio_download_failure_sends_event() {
MockBackend backend;
MockMedia media;
MockUi ui;
backend.next_voice_response.ok = true;
backend.next_voice_response.reply_audio_url = "http://example.com/reply.wav";
backend.download_succeeds = false;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
bool ok = vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_TRUE(ok);
TEST_ASSERT_EQUAL_STRING("playback_failed", backend.last_event_name.c_str());
TEST_ASSERT_EQUAL_STRING("tts reply download failed", backend.last_event_detail.c_str());
}
void test_vc_audio_playback_failure_sends_event() {
MockBackend backend;
MockMedia media;
MockUi ui;
backend.next_voice_response.ok = true;
backend.next_voice_response.reply_audio_url = "http://example.com/reply.wav";
backend.download_succeeds = true;
backend.download_audio = DummyWav();
media.play_reply_result = false;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
bool ok = vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_TRUE(ok);
TEST_ASSERT_EQUAL_STRING("playback_failed", backend.last_event_name.c_str());
TEST_ASSERT_EQUAL_STRING("tts reply playback failed", backend.last_event_detail.c_str());
}
void test_vc_restore_called_with_resume_flag() {
MockBackend backend;
MockMedia media;
MockUi ui;
backend.next_voice_response.ok = true;
backend.next_voice_response.reply_audio_url = "";
backend.next_voice_response.intent.resume_media_after_tts = true;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_TRUE(media.last_restore_resume);
}
void test_vc_prepare_receives_player_action() {
MockBackend backend;
MockMedia media;
MockUi ui;
backend.next_voice_response.ok = true;
backend.next_voice_response.reply_audio_url = "";
backend.next_voice_response.player_action = PlayerAction::kDuck;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_EQUAL(static_cast<int>(PlayerAction::kDuck),
static_cast<int>(media.last_prepare_action));
}
void test_vc_can_start_new_session_after_success() {
MockBackend backend;
MockMedia media;
MockUi ui;
backend.next_voice_response.ok = true;
backend.next_voice_response.reply_audio_url = "";
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
TEST_ASSERT_TRUE(vc.BeginPushToTalk());
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording), static_cast<int>(vc.phase()));
}
void test_vc_can_start_new_session_after_error() {
MockBackend backend;
MockMedia media;
MockUi ui;
backend.next_voice_response.ok = false;
backend.next_voice_response.error = "fail";
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
vc.CompletePushToTalk(DummyWav());
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
TEST_ASSERT_TRUE(vc.BeginPushToTalk());
}
// ===========================================================================
// OtaManager: version comparison logic
// ===========================================================================
void test_ota_version_equal_means_up_to_date() {
std::string current = "1.2.3";
std::string latest = "1.2.3";
int cmp = FwCompareVersions(current, latest);
TEST_ASSERT_TRUE(cmp >= 0);
}
void test_ota_version_newer_means_update_available() {
std::string current = "1.0.0";
std::string latest = "1.0.1";
int cmp = FwCompareVersions(current, latest);
TEST_ASSERT_TRUE(cmp < 0);
}
void test_ota_version_current_ahead_means_up_to_date() {
std::string current = "2.0.0";
std::string latest = "1.9.9";
int cmp = FwCompareVersions(current, latest);
TEST_ASSERT_TRUE(cmp >= 0);
}
void test_ota_version_major_minor_patch_all_checked() {
TEST_ASSERT_TRUE(FwCompareVersions("1.0.0", "2.0.0") < 0);
TEST_ASSERT_TRUE(FwCompareVersions("1.0.0", "1.1.0") < 0);
TEST_ASSERT_TRUE(FwCompareVersions("1.0.0", "1.0.1") < 0);
TEST_ASSERT_TRUE(FwCompareVersions("2.0.0", "1.0.0") > 0);
TEST_ASSERT_TRUE(FwCompareVersions("1.1.0", "1.0.0") > 0);
TEST_ASSERT_TRUE(FwCompareVersions("1.0.1", "1.0.0") > 0);
}
void test_ota_version_partial_strings() {
TEST_ASSERT_EQUAL_INT(0, FwCompareVersions("1.0.0", "1.0"));
TEST_ASSERT_EQUAL_INT(0, FwCompareVersions("1.0.0", "1"));
}
void test_ota_version_invalid_strings() {
TEST_ASSERT_EQUAL_INT(0, FwCompareVersions("abc", "0.0.0"));
}
// ---------------------------------------------------------------------------
// OtaCheckResult state machine
// ---------------------------------------------------------------------------
void test_ota_check_result_up_to_date_flow() {
OtaCheckResult info;
info.status = OtaCheckResult::Status::kUpToDate;
info.latest_version = "1.0.0";
TEST_ASSERT_EQUAL(static_cast<int>(OtaCheckResult::Status::kUpToDate),
static_cast<int>(info.status));
}
void test_ota_check_result_check_failed_flow() {
OtaCheckResult info;
info.status = OtaCheckResult::Status::kCheckFailed;
info.error = "HTTP -1";
TEST_ASSERT_EQUAL(static_cast<int>(OtaCheckResult::Status::kCheckFailed),
static_cast<int>(info.status));
TEST_ASSERT_EQUAL_STRING("HTTP -1", info.error.c_str());
}
void test_ota_check_result_update_flash_ok_flow() {
OtaCheckResult info;
info.status = OtaCheckResult::Status::kUpdateAvailable;
info.latest_version = "1.1.0";
info.url = "http://example.com/firmware.bin";
// Simulate CheckAndUpdate: flash succeeds
bool flash_ok = true;
if (flash_ok) {
info.status = OtaCheckResult::Status::kFlashOk;
} else {
info.status = OtaCheckResult::Status::kFlashFailed;
info.error = "flash error";
}
TEST_ASSERT_EQUAL(static_cast<int>(OtaCheckResult::Status::kFlashOk),
static_cast<int>(info.status));
}
void test_ota_check_result_update_flash_failed_flow() {
OtaCheckResult info;
info.status = OtaCheckResult::Status::kUpdateAvailable;
info.latest_version = "1.1.0";
info.url = "http://example.com/firmware.bin";
bool flash_ok = false;
if (flash_ok) {
info.status = OtaCheckResult::Status::kFlashOk;
} else {
info.status = OtaCheckResult::Status::kFlashFailed;
info.error = "flash error";
}
TEST_ASSERT_EQUAL(static_cast<int>(OtaCheckResult::Status::kFlashFailed),
static_cast<int>(info.status));
TEST_ASSERT_EQUAL_STRING("flash error", info.error.c_str());
}
void test_ota_check_result_defaults() {
OtaCheckResult r;
TEST_ASSERT_EQUAL(static_cast<int>(OtaCheckResult::Status::kCheckFailed),
static_cast<int>(r.status));
TEST_ASSERT_TRUE(r.latest_version.empty());
TEST_ASSERT_TRUE(r.url.empty());
TEST_ASSERT_TRUE(r.error.empty());
}
void test_ota_invalid_backend_url() {
TEST_ASSERT_FALSE(FwIsValidBackendUrl(""));
TEST_ASSERT_FALSE(FwIsValidBackendUrl("ftp://foo"));
TEST_ASSERT_TRUE(FwIsValidBackendUrl("http://192.168.1.42:8000"));
}
void test_ota_manager_constructor() {
OtaManager ota("1.2.3");
TEST_ASSERT_EQUAL_STRING("1.2.3", ota.current_version().c_str());
}
void test_ota_manager_set_backend_url() {
OtaManager ota("1.0.0");
ota.SetBackendUrl("http://test:9999");
TEST_ASSERT_EQUAL_STRING("1.0.0", ota.current_version().c_str());
}
// ===========================================================================
// UI rendering verification
// ===========================================================================
void test_vc_boot_renders_idle_state() {
MockBackend backend;
MockMedia media;
MockUi ui;
media.snapshot.mode = MediaMode::kRadio;
media.snapshot.volume = 55;
media.snapshot.station = "FIP";
media.snapshot.playing = true;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
TEST_ASSERT_EQUAL_STRING("pret", ui.last_headline.c_str());
TEST_ASSERT_FALSE(ui.last_show_ring);
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(ui.last_phase));
}
void test_vc_begin_ptt_renders_recording_state() {
MockBackend backend;
MockMedia media;
MockUi ui;
VoiceController vc("dev-001", backend, media, ui);
vc.Boot();
vc.BeginPushToTalk();
TEST_ASSERT_EQUAL_STRING("j'ecoute", ui.last_headline.c_str());
TEST_ASSERT_TRUE(ui.last_show_ring);
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording), static_cast<int>(ui.last_phase));
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
int main(int, char **) {
UNITY_BEGIN();
// VoiceController state transitions
RUN_TEST(test_vc_initial_phase_is_idle);
RUN_TEST(test_vc_boot_sends_event_and_stays_idle);
RUN_TEST(test_vc_begin_ptt_transitions_to_recording);
RUN_TEST(test_vc_begin_ptt_fails_when_not_idle);
RUN_TEST(test_vc_complete_ptt_fails_when_not_recording);
RUN_TEST(test_vc_complete_ptt_empty_audio_fails);
RUN_TEST(test_vc_full_successful_flow_idle_to_idle);
RUN_TEST(test_vc_successful_flow_with_audio_download);
RUN_TEST(test_vc_backend_error_returns_to_idle);
RUN_TEST(test_vc_backend_error_empty_message_uses_default);
RUN_TEST(test_vc_audio_download_failure_sends_event);
RUN_TEST(test_vc_audio_playback_failure_sends_event);
RUN_TEST(test_vc_restore_called_with_resume_flag);
RUN_TEST(test_vc_prepare_receives_player_action);
RUN_TEST(test_vc_can_start_new_session_after_success);
RUN_TEST(test_vc_can_start_new_session_after_error);
// UI rendering verification
RUN_TEST(test_vc_boot_renders_idle_state);
RUN_TEST(test_vc_begin_ptt_renders_recording_state);
// OtaManager version comparison
RUN_TEST(test_ota_version_equal_means_up_to_date);
RUN_TEST(test_ota_version_newer_means_update_available);
RUN_TEST(test_ota_version_current_ahead_means_up_to_date);
RUN_TEST(test_ota_version_major_minor_patch_all_checked);
RUN_TEST(test_ota_version_partial_strings);
RUN_TEST(test_ota_version_invalid_strings);
// OtaManager CheckAndUpdate flow
RUN_TEST(test_ota_check_result_up_to_date_flow);
RUN_TEST(test_ota_check_result_check_failed_flow);
RUN_TEST(test_ota_check_result_update_flash_ok_flow);
RUN_TEST(test_ota_check_result_update_flash_failed_flow);
RUN_TEST(test_ota_check_result_defaults);
RUN_TEST(test_ota_invalid_backend_url);
RUN_TEST(test_ota_manager_constructor);
RUN_TEST(test_ota_manager_set_backend_url);
return UNITY_END();
}