diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 7617f556..db353f2a 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -313,6 +313,17 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") "-Wl,--wrap=ma_device_init" "-Wl,--wrap=ma_device_start" "-Wl,--wrap=ma_device_uninit" "-Wl,--wrap=audio_context_failed") add_test(NAME test_audio_recovery COMMAND test_audio_recovery) set_tests_properties(test_audio_recovery PROPERTIES TIMEOUT 15) + add_executable(test_radio_panel tests/test_radio_panel.cpp src/radiopanel.cpp src/radio_audio_engine.cpp + src/sound_manager.cpp src/voice_audio_io.cpp src/audio_device.cpp + transport/miniaudio_impl.c transport/audio_diagnostics.cpp transport/config_updater.cpp) + target_include_directories(test_radio_panel PRIVATE ${UTUN_INCLUDE_DIRS}) + target_link_libraries(test_radio_panel PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread dl) + target_link_options(test_radio_panel PRIVATE "-Wl,--wrap=ma_context_init" "-Wl,--wrap=ma_device_start" + "-Wl,--wrap=radio_talk_begin" "-Wl,--wrap=radio_talk_end" "-Wl,--wrap=radio_talk_send" + "-Wl,--wrap=silero_vad_create_default" "-Wl,--wrap=silero_vad_destroy" "-Wl,--wrap=silero_vad_reset" + "-Wl,--wrap=silero_vad_process") + add_test(NAME test_radio_panel COMMAND test_radio_panel) + set_tests_properties(test_radio_panel PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") add_executable(test_ptt_portal tests/test_ptt_portal.cpp src/ptt_portal.cpp) target_link_libraries(test_ptt_portal PRIVATE ${QT_DBUS} ${QT_TEST_LIB} utun pthread) find_program(DBUS_RUN_SESSION dbus-run-session) diff --git a/tools/chatgui/tests/test_radio_panel.cpp b/tools/chatgui/tests/test_radio_panel.cpp new file mode 100644 index 00000000..ffaef7f7 --- /dev/null +++ b/tools/chatgui/tests/test_radio_panel.cpp @@ -0,0 +1,180 @@ +/* Настоящие Qt-кнопки, null audio backend и общий radio_audio; подменяем только вероятность VAD и сетевой выход. */ +#include "../src/radiopanel.h" +#include "../src/radio_audio_engine.h" +#include "../src/sound_manager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "utun_instance.h" +#include "radio/radio_audio.h" +#include "radio/radio.h" +#include "chat/chat_setting.h" +#include "u_async.h" +#include "debug_config.h" +#include "silero_vad.h" +} + +#define REQUIRE(condition) do { if (!(condition)) qFatal("radio panel line %d: %s", __LINE__, #condition); } while (0) +static UTUN_INSTANCE instance{}; +static std::atomic probability{0}; +static bool detectorAvailable = true; +static int begins, ends, packets, inputs, outputs; + +extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; } +extern "C" int gui_bridge_is_uasync_ready() { return instance.ua != nullptr; } +extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) { uasync_post(instance.ua, fn, arg); } +extern "C" int __wrap_radio_talk_begin(UTUN_INSTANCE*, uint64_t) { ++begins; return 0; } +extern "C" int __wrap_radio_talk_end(UTUN_INSTANCE*, uint64_t) { ++ends; return 0; } +extern "C" int __wrap_radio_talk_send(UTUN_INSTANCE*, uint64_t, const uint8_t*, int) { ++packets; return 0; } +extern "C" silero_vad_t* __wrap_silero_vad_create_default() { return detectorAvailable ? reinterpret_cast(1) : nullptr; } +extern "C" void __wrap_silero_vad_destroy(silero_vad_t*) {} +extern "C" void __wrap_silero_vad_reset(silero_vad_t*) {} +extern "C" int __wrap_silero_vad_process(silero_vad_t*, const float*, float* result) { *result = probability.load(); return 0; } +extern "C" ma_result __real_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context*); +extern "C" ma_result __real_ma_device_start(ma_device*); +extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context* context) { + ma_backend backend = ma_backend_null; + return __real_ma_context_init(&backend, 1, nullptr, context); +} +extern "C" ma_result __wrap_ma_device_start(ma_device* device) { + if (device->type == ma_device_type_capture) ++inputs; else ++outputs; + return __real_ma_device_start(device); +} + +static void pump(int milliseconds) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < milliseconds) { + uasync_poll(instance.ua, 10); + QApplication::processEvents(); + } +} + +template static void await(Predicate ready) { + QElapsedTimer timer; + timer.start(); + while (!ready() && timer.elapsed() < 2500) pump(10); + REQUIRE(ready()); +} + +int main(int argc, char** argv) { + QApplication app(argc, argv); + debug_config_init(); + debug_set_level(DEBUG_LEVEL_WARN); + debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_INFO); + instance.ua = uasync_create(); + REQUIRE(instance.ua && radio_audio_init(&instance) == 0); + REQUIRE(chat_setting_set(&instance, "radio_vad_enabled", "0") == 0); + auto* manager = SoundManager::instance(); + REQUIRE(manager->init()); + QTemporaryDir directory; + REQUIRE(directory.isValid()); + QString configPath = directory.filePath("radio.cfg"); + QFile config(configPath); + REQUIRE(config.open(QIODevice::WriteOnly)); + REQUIRE(config.write("[chatserver]\nradio_vad_enabled=0\nradio_opus_bitrate=32\n") > 0); + config.close(); + auto* engine = RadioAudioEngine::instance(); + engine->loadSettings(configPath); + await([&]() { return engine->settingsReady() && !engine->settingsBusy(); }); + REQUIRE(!engine->vadEnabled() && engine->setting("radio_vad_compressor_max_gain_db") == 15); + RadioPanel panel; + panel.resize(580, 50); + panel.show(); + auto* vad = panel.findChild("radioVadButton"); + auto* ptt = panel.findChild("radioPttButton"); + auto* status = panel.findChild("radioStatus"); + REQUIRE(vad && ptt && status && !vad->isEnabled()); + REQUIRE(engine->start(7) == 0); + await([&]() { return engine->captureAvailable() && ptt->isEnabled(); }); + REQUIRE(!vad->isChecked() && vad->isEnabled()); + REQUIRE(vad->geometry().right() < ptt->geometry().left()); + int oldOutputs = outputs, oldInputs = inputs; + vad->click(); + REQUIRE(engine->settingsBusy() && !vad->isEnabled()); + await([&]() { return !engine->settingsBusy() && engine->vadAvailable(); }); + REQUIRE(vad->isChecked() && outputs == oldOutputs && inputs == oldInputs + 1); + pump(750); // Полное кольцо prefetch до начала речи. + int previousPackets = packets; + probability = 0.9f; + await([&]() { return engine->isTransmitting(); }); + REQUIRE(!engine->isTalking() && ptt->property("transmitting").toBool() && begins == 1); + REQUIRE(packets >= previousPackets + 30); + int beforeManual = begins; + QTest::mousePress(ptt, Qt::LeftButton); + REQUIRE(engine->isTalking()); + engine->setHotkeyTalking(true); + QTest::mouseRelease(ptt, Qt::LeftButton); + REQUIRE(engine->isTalking()); + engine->setHotkeyTalking(false); + pump(100); + REQUIRE(!engine->isTalking() && engine->isTransmitting() && begins == beforeManual); + probability = 0; + await([&]() { return !engine->isTransmitting(); }); + REQUIRE(!ptt->property("transmitting").toBool() && ends == 1); + qInfo("radio panel: automatic TX, 600ms prefetch, actual UI state and manual/hotkey handover PASS"); + + engine->setHotkeyTalking(true); + await([&]() { return engine->isTransmitting(); }); + oldOutputs = outputs; oldInputs = inputs; + vad->click(); + await([&]() { return !engine->settingsBusy() && !engine->vadEnabled() && engine->isTransmitting(); }); + REQUIRE(engine->isTalking() && !engine->vadAvailable() && outputs == oldOutputs && inputs == oldInputs + 1); + engine->setHotkeyTalking(false); + await([&]() { return !engine->isTransmitting(); }); + REQUIRE(engine->applySettings({{"radio_vad_threshold", 65}, {"radio_vad_hangover_ms", 400}, + {"radio_vad_compressor_max_gain_db", 30}, {"radio_compressor_max_gain_db", 6}})); + await([&]() { return !engine->settingsBusy(); }); + REQUIRE(engine->setting("radio_vad_compressor_max_gain_db") == 30 && engine->setting("radio_compressor_max_gain_db") == 6); + REQUIRE(!engine->applySettings({{"radio_vad_compressor_max_gain_db", 61}})); + REQUIRE(engine->setting("radio_vad_compressor_max_gain_db") == 30); + + detectorAvailable = false; + vad->click(); + await([&]() { return !engine->settingsBusy(); }); + REQUIRE(vad->isChecked() && engine->captureAvailable() && !engine->vadAvailable() && status->text() == "VAD недоступен"); + QTest::mousePress(ptt, Qt::LeftButton); + await([&]() { return engine->isTransmitting(); }); + QTest::mouseRelease(ptt, Qt::LeftButton); + await([&]() { return !engine->isTransmitting(); }); + qInfo("radio panel: capture-only restart preserves output/manual owner; VAD failure leaves manual PTT working PASS"); + + engine->stop(); + REQUIRE(!engine->isTransmitting() && !engine->captureAvailable() && !vad->isEnabled()); + REQUIRE(config.open(QIODevice::ReadOnly)); + QByteArray saved = config.readAll(); + config.close(); + REQUIRE(saved.contains("radio_vad_enabled=1") && saved.contains("radio_vad_threshold=65") && + saved.contains("radio_vad_hangover_ms=400") && saved.contains("radio_vad_compressor_max_gain_db=30") && + saved.contains("radio_opus_bitrate=32")); + engine->loadSettings(configPath); + await([&]() { return !engine->settingsBusy(); }); + REQUIRE(engine->vadEnabled() && engine->setting("radio_vad_threshold") == 65); + detectorAvailable = true; + REQUIRE(engine->start(8) == 0); + await([&]() { return engine->vadAvailable(); }); + REQUIRE(engine->groupId() == 8 && vad->isChecked()); + engine->stop(); + engine->loadSettings(directory.filePath("missing.cfg")); + await([&]() { return !engine->settingsBusy(); }); + REQUIRE(!engine->applySettings({{"radio_vad_enabled", 0}}) && engine->vadEnabled() && !engine->settingsError().isEmpty()); + for (auto* button : panel.findChildren()) { + REQUIRE(QMetaObject::invokeMethod(button->menu(), "aboutToShow", Qt::DirectConnection)); + REQUIRE(!button->menu()->actions().isEmpty()); + } + panel.grab().save("/tmp/desktop-radio-panel.png"); + radio_audio_destroy(&instance); + uasync_destroy(instance.ua, 0); + instance.ua = nullptr; + manager->shutdown(); + qInfo("radio panel: saved settings, channel change, device menus, range validation and config failure PASS"); + return 0; +}