diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index b47b2c4c..5c00672f 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -668,6 +668,15 @@ if(UNIX AND NOT APPLE) "-Wl,--wrap=silero_vad_create_default" "-Wl,--wrap=silero_vad_destroy" "-Wl,--wrap=silero_vad_reset" "-Wl,--wrap=silero_vad_process" "-Wl,--wrap=get_time_tb") add_test(NAME test_voice_tx COMMAND test_voice_tx) + add_executable(test_call_audio_tx tests/test_call_audio_tx.cpp) + target_include_directories(test_call_audio_tx PRIVATE ${UTUN_INCLUDE_DIRS}) + target_link_libraries(test_call_audio_tx PRIVATE utun_voice utun pthread) + target_link_options(test_call_audio_tx PRIVATE "-Wl,--wrap=call_send_media" "-Wl,--wrap=chat_setting_get_int" "-Wl,--wrap=get_time_tb") + foreach(scenario fresh same_owner reinit idempotent different_instance reused_address detached_drain + discarded_loop cross_loop mixed_calls current_expiry rebind_storm detached_engine) + add_test(NAME test_call_audio_tx_${scenario} COMMAND test_call_audio_tx ${scenario}) + set_tests_properties(test_call_audio_tx_${scenario} PROPERTIES TIMEOUT 15) + endforeach() endif() # smoke-тест видео-декодера (FFmpeg). Ручной запуск: test_video_engine diff --git a/tools/chatgui/tests/test_call_audio_tx.cpp b/tools/chatgui/tests/test_call_audio_tx.cpp new file mode 100644 index 00000000..eafe5a85 --- /dev/null +++ b/tools/chatgui/tests/test_call_audio_tx.cpp @@ -0,0 +1,215 @@ +/* Настоящие Opus/uasync; наблюдаем TX и логи без доступа к private-состоянию call_audio. + * Каждый сценарий запускается отдельным процессом, один uasync на поток. */ +extern "C" { +#include "utun_instance.h" +#include "call/call_audio.h" +#include "debug_config.h" +#include "u_async.h" +#include "mem.h" +} +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define REQUIRE(x) do { if (!(x)) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call-audio-tx:%d %s", __LINE__, #x); abort(); } } while (0) +static constexpr uint64_t old_id = 41, new_id = 42; +static std::array pcm{}; +static std::array mask{}; +static std::atomic test_time{100000}; +static unsigned sent_new, sent_old, extra_new, expected_extra, extra_first = 8; +static std::vector messages; + +extern "C" uint64_t __wrap_get_time_tb() { return test_time.load(); } +extern "C" int __wrap_chat_setting_get_int(UTUN_INSTANCE*, const char* key, int fallback) { + return !strcmp(key, "call_compressor_enabled") || !strcmp(key, "aec_enabled") ? 0 : fallback; +} +extern "C" int __wrap_call_send_media(UTUN_INSTANCE*, uint64_t id, const uint8_t* data, int len, + const call_capture_time* timing) { + REQUIRE(data && len > 0 && timing); + REQUIRE(id == old_id || id == new_id); + if (id == new_id) ++sent_new; else ++sent_old; + return 0; +} + +static void capture_diagnostic(int, const char* category, const char* message) { + if (category && !strcmp(category, "call")) messages.emplace_back(message); +} +static bool contains(const char* text) { + return std::any_of(messages.begin(), messages.end(), [text](const std::string& s) { return s.find(text) != std::string::npos; }); +} +static void stats(uint64_t id) { + messages.clear(); + REQUIRE(call_audio_get_stats(id, nullptr, nullptr, nullptr, nullptr, nullptr) == 0); +} +static unsigned feed(uint64_t id, unsigned first, unsigned count = 8) { + unsigned accepted = 0; + for (unsigned i = 0; i < count; ++i) + accepted += call_audio_feed_prepared_pcm(id, pcm.data(), mask.data(), int64_t(first + i) * 20000) == 0; + return accepted; +} +static void extra(void*) { + stats(new_id); + extra_new = feed(new_id, extra_first); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "call-audio-tx: queued extra accepted=%u expected=%u", extra_new, expected_extra); + REQUIRE(extra_new == expected_extra); +} +static void begin(UTUN_INSTANCE& inst, uint64_t id) { + REQUIRE(call_audio_init(&inst) == 0); + REQUIRE(call_audio_start_prepared(id) == 0); +} +static void unbind(UTUN_INSTANCE& inst) { call_audio_stop(); call_audio_destroy(&inst); } +static void drain(UASYNC* ua) { + uasync_poll(ua, 0); + uasync_poll(ua, 0); /* Tasks, опубликованные из extra, исполняются следующим проходом. */ +} + +int main(int argc, char** argv) { + debug_config_init(); + debug_set_level(DEBUG_LEVEL_ERROR); + debug_set_category_level(DEBUG_CATEGORY_CALL, DEBUG_LEVEL_TRACE); + debug_set_log_hook(capture_diagnostic); + REQUIRE(argc == 2); + const char* mode = argv[1]; + size_t allocated = u_get_allocated_count(); + UTUN_INSTANCE inst{}, other{}; + UTUN_INSTANCE* current = &inst; + std::thread worker; + std::promise old_ready; + std::promise retire_old; + + if (!strcmp(mode, "cross_loop")) { + auto ready = old_ready.get_future(); + auto retire = retire_old.get_future(); + worker = std::thread([&] { + UTUN_INSTANCE old{}; + old.ua = uasync_create(); REQUIRE(old.ua); + begin(old, old_id); + unsigned count = feed(old_id, 0); + unbind(old); + old_ready.set_value(count); + retire.wait(); + drain(old.ua); + uasync_destroy(old.ua, 0); + }); + REQUIRE(ready.get() == 8); + } + + UASYNC* ua = uasync_create(); REQUIRE(ua); + inst.ua = other.ua = ua; + unsigned initial_new = 0, expected_initial = 8; + if (!strcmp(mode, "fresh") || !strcmp(mode, "idempotent")) { + uasync_post(ua, extra, nullptr); + begin(inst, new_id); + initial_new = feed(new_id, 0); + if (!strcmp(mode, "idempotent")) { + REQUIRE(call_audio_init(&inst) == 0); + REQUIRE(call_audio_init(&inst) == 0); + } + } else if (!strcmp(mode, "same_owner") || !strcmp(mode, "reinit") || !strcmp(mode, "mixed_calls")) { + unsigned old_count = !strcmp(mode, "mixed_calls") ? 4 : 8; + begin(inst, old_id); + REQUIRE(feed(old_id, 0, old_count) == old_count); + uasync_post(ua, extra, nullptr); + call_audio_stop(); + if (!strcmp(mode, "reinit")) call_audio_destroy(&inst); + begin(inst, new_id); + initial_new = feed(new_id, 0); + if (strcmp(mode, "reinit")) { expected_initial = 8 - old_count; expected_extra = old_count; } + } else if (!strcmp(mode, "different_instance") || !strcmp(mode, "reused_address")) { + begin(inst, old_id); + REQUIRE(feed(old_id, 0) == 8); + uasync_post(ua, extra, nullptr); + if (!strcmp(mode, "different_instance")) { + call_audio_stop(); current = &other; + REQUIRE(call_audio_init(current) == 0); + call_audio_destroy(&inst); /* Поздний destroy прежнего владельца не снимает нового. */ + } else { + unbind(inst); + inst = UTUN_INSTANCE{}; inst.ua = ua; inst.node_id = 2; + } + begin(*current, new_id); + initial_new = feed(new_id, 0); + } else if (!strcmp(mode, "detached_drain") || !strcmp(mode, "discarded_loop")) { + begin(inst, old_id); + REQUIRE(feed(old_id, 0) == 8); + unbind(inst); + if (!strcmp(mode, "detached_drain")) drain(ua); + else { + uasync_destroy(ua, 0); /* Ожидающие tasks удаляются без callback. */ + ua = uasync_create(); REQUIRE(ua); + inst.ua = ua; + } + uasync_post(ua, extra, nullptr); + begin(inst, new_id); + initial_new = feed(new_id, 0); + } else if (!strcmp(mode, "cross_loop")) { + uasync_post(ua, extra, nullptr); + begin(inst, new_id); + initial_new = feed(new_id, 0); + retire_old.set_value(); worker.join(); /* Старый loop исполняет tasks после новой привязки. */ + REQUIRE(contains("owner=0")); + } else if (!strcmp(mode, "current_expiry")) { + begin(inst, new_id); + REQUIRE(feed(new_id, 0) == 8); + test_time += 2001; drain(ua); + REQUIRE(sent_new == 0); + stats(new_id); + REQUIRE(contains("posts=8") && contains("TX dropped=8") && contains("age:1")); + uasync_post(ua, extra, nullptr); + initial_new = feed(new_id, 8); extra_first = 16; + } else if (!strcmp(mode, "rebind_storm")) { + for (unsigned i = 0; i < 16; ++i) { + begin(inst, old_id); + REQUIRE(feed(old_id, 0) == 8); + unbind(inst); + } + uasync_post(ua, extra, nullptr); + begin(inst, new_id); + initial_new = feed(new_id, 0); + } else if (!strcmp(mode, "detached_engine")) { + begin(inst, old_id); + for (unsigned round = 0; round < 2; ++round) { + REQUIRE(feed(old_id, round * 8) == 8); + stats(old_id); /* Убрать feed-статистику до проверки поздних callbacks. */ + call_audio_destroy(&inst); + REQUIRE(call_audio_init(&inst) == 0); /* Encoder и call generation пока не меняются. */ + if (round) test_time += 2001; + drain(ua); + REQUIRE(sent_old == 0 && contains("owner=0")); + stats(old_id); + REQUIRE(!contains("TX timing") && !contains("TX dropped=")); + } + call_audio_stop(); + uasync_post(ua, extra, nullptr); + begin(inst, new_id); + initial_new = feed(new_id, 0); + } else { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call-audio-tx: unknown scenario=%s", mode); + abort(); + } + + REQUIRE(initial_new == expected_initial); + drain(ua); + REQUIRE(extra_new == expected_extra && sent_new == 8 && sent_old == 0); + stats(new_id); + REQUIRE(contains("posts=8") && contains("pending=0")); + /* После drain разрешены ровно восемь новых задач; девятая снова отклоняется. */ + REQUIRE(feed(new_id, extra_first + 8) == 8); + REQUIRE(feed(new_id, extra_first + 16, 1) == 0); + unbind(*current); + uasync_destroy(ua, 0); /* И этот финальный хвост освобождается без callback. */ + REQUIRE(sent_new == 8 && sent_old == 0); + size_t remaining = u_get_allocated_count(); + REQUIRE(remaining == allocated); + debug_set_log_hook(nullptr); + DEBUG_INFO(DEBUG_CATEGORY_CALL, + "call-audio-tx: scenario=%s initial=%u extra=%u sent_new=%u sent_old=%u allocations=%zu->%zu PASS", + mode, initial_new, extra_new, sent_new, sent_old, allocated, remaining); + return 0; +}