Files
utun2/tools/chatgui/tests/test_radio_panel.cpp
T

504 lines
28 KiB
C++

/* Настоящие Qt-кнопки, null audio backend и общий radio_audio; подменяем только вероятность VAD и сетевой выход. */
#include "../src/radiopanel.h"
#include "../src/radiosettingspage.h"
#include "../src/ptt_hotkey_manager.h"
#include <QCheckBox>
#include <QProcess>
#include <QSpinBox>
#include "../src/radio_audio_engine.h"
#include "../src/sound_manager.h"
#include "../src/memberlistmodel.h"
#include <QApplication>
#include <QPushButton>
#include <QLabel>
#include <QToolButton>
#include <QMenu>
#include <QTemporaryDir>
#include <QFile>
#include <QElapsedTimer>
#include <QTest>
#include <QSignalSpy>
#include <atomic>
extern "C" {
#include "utun_instance.h"
#include "radio/radio_audio.h"
#include "radio/radio.h"
#include "chat/chat_setting.h"
#include "chat/chat_member.h"
#include "config_parser.h"
#include "u_async.h"
#include "debug_config.h"
#include "silero_vad.h"
#include "mem.h"
}
#include <X11/Xlib.h>
#include <X11/keysym.h>
#define REQUIRE(condition) do { if (!(condition)) qFatal("radio panel line %d: %s", __LINE__, #condition); } while (0)
static UTUN_INSTANCE instance{};
static std::atomic<float> probability{0};
static bool detectorAvailable = true;
static bool failCapture;
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) {
// Стенд проверяет аудио-движок и собственную строку, без чат-сервиса и его БД.
if (fn == chat_core_request_member_list_trampoline || fn == chat_core_request_member_rtt_trampoline) { u_free(arg); return; }
uasync_post(instance.ua, fn, arg);
}
extern "C" int gui_bridge_post_uasync_owned_fn(void (*fn)(void*), void* arg) {
if (!instance.ua) return -1;
gui_bridge_post_uasync_fn(fn, arg);
return 0;
}
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<silero_vad_t*>(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;
if (failCapture && device->type == ma_device_type_capture) return MA_ERROR;
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<class Predicate> 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_auto_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_auto_enabled=0\nradio_opus_bitrate=32\n") > 0);
config.close();
auto* engine = RadioAudioEngine::instance();
MemberListModel members;
members.setChannel("7", false);
chat_member_display self{}; self.node_id = 1; self.is_self = 1;
uint8_t member[CHAT_MEMBER_DISPLAY_SIZE]; chat_core_serialize_member(&self, member);
members.onMemberList("7", 1, member, sizeof(member));
QObject::connect(engine, &RadioAudioEngine::stateChanged, &members, [&] {
members.setRadioState(engine->groupId(), engine->isActive(), engine->isTransmitting());
});
auto selfSpeaking = [&] { return members.index(0, 0).data(MemberListModel::RadioSpeakingRole).toBool(); };
engine->loadSettings(configPath);
await([&]() { return engine->settingsReady() && !engine->settingsBusy(); });
REQUIRE(!engine->autoEnabled() && engine->setting("radio_vad_compressor_max_gain_db") == 15);
RadioPanel panel;
panel.resize(580, 50);
panel.show();
auto* vad = panel.findChild<QPushButton*>("radioAutoButton");
auto* ptt = panel.findChild<QPushButton*>("radioPttButton");
auto* status = panel.findChild<QLabel*>("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->autoAvailable(); });
REQUIRE(vad->isChecked() && outputs == oldOutputs && inputs == oldInputs + 1);
// Удержание открывает редактор без переключения автоматики и перезапуска захвата.
oldInputs = inputs;
QTest::mousePress(vad, Qt::LeftButton); pump(550);
auto* popup = panel.findChild<QMenu*>("radioActivationPopup");
REQUIRE(popup && popup->isVisible() && engine->autoEnabled() && inputs == oldInputs);
QTest::mouseRelease(vad, Qt::LeftButton);
REQUIRE(popup->isVisible() && engine->autoEnabled() && !engine->settingsBusy());
auto* quickVad = popup->findChild<QPushButton*>("radioModeVad");
auto* quickLevel = popup->findChild<QPushButton*>("radioModeLevel");
auto* quickThreshold = popup->findChild<QSpinBox*>("radioVadThreshold");
auto* quickOn = popup->findChild<QSpinBox*>("radioLevelOn");
auto* quickApply = popup->findChild<QPushButton*>("radioActivationApply");
auto* quickProbability = popup->findChild<QLabel*>("radioVadText");
auto* quickGain = popup->findChild<QLabel*>("radioVadGainText");
REQUIRE(quickVad && quickLevel && quickThreshold && quickOn && quickApply && quickProbability && quickGain);
quickLevel->click(); REQUIRE(quickOn->isEnabled() && quickThreshold->isEnabled() && engine->autoMode() == 1);
quickVad->click(); quickThreshold->setValue(60); REQUIRE(quickApply->isEnabled());
quickApply->click(); await([&] { return !engine->settingsBusy() && engine->vadValid(); });
REQUIRE(engine->setting("radio_vad_threshold") == 60 && engine->autoEnabled() && !quickApply->isEnabled());
probability = 0.25f;
await([&] { return quickProbability->text().contains("25.0%") && quickGain->text().contains("dB"); });
REQUIRE(engine->vadCompressorEnabled() && engine->vadGain() > 0);
REQUIRE(popup->grab().save("/tmp/desktop-radio-activation-popup.png"));
quickThreshold->setValue(70);
QTest::keyClick(popup, Qt::Key_Escape); REQUIRE(!popup->isVisible());
QMetaObject::invokeMethod(vad, "customContextMenuRequested", Q_ARG(QPoint, QPoint()));
REQUIRE(popup->isVisible() && quickThreshold->value() == 60);
popup->hide(); probability = 0;
qInfo("radio panel: hold/context popup, no toggle, direct modes, both thresholds, apply/cancel, live probability/gain PASS");
pump(750); // Полное кольцо prefetch до начала речи.
int previousPackets = packets;
probability = 0.9f;
await([&]() { return engine->isTransmitting() && begins == 1 && packets >= previousPackets + 30; });
REQUIRE(!engine->isTalking() && ptt->property("transmitting").toBool() && begins == 1);
REQUIRE(selfSpeaking()); // VAD включает собственный динамик без нажатой PTT.
REQUIRE(panel.grab().save("/tmp/desktop-radio-vad-meter.png"));
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() && ends == 1; });
REQUIRE(!ptt->property("transmitting").toBool() && ends == 1);
REQUIRE(!selfSpeaking());
qInfo("radio panel: automatic TX, 600ms prefetch, actual UI state and manual/hotkey handover PASS");
engine->setHotkeyTalking(true);
await([&]() { return engine->isTransmitting(); });
REQUIRE(selfSpeaking());
oldOutputs = outputs; oldInputs = inputs;
vad->click();
await([&]() { return !engine->settingsBusy() && !engine->autoEnabled() && engine->isTransmitting(); });
REQUIRE(engine->isTalking() && !engine->autoAvailable() && outputs == oldOutputs && inputs == oldInputs + 1);
engine->setHotkeyTalking(false);
await([&]() { return !engine->isTransmitting(); });
REQUIRE(!selfSpeaking());
// Реальная последовательность double-click: первый click, второе нажатие DoubleClick, release.
auto latchPtt = [&]() {
QTest::mouseClick(ptt, Qt::LeftButton);
QTest::mouseDClick(ptt, Qt::LeftButton);
QTest::mouseRelease(ptt, Qt::LeftButton);
REQUIRE(engine->isTalking() && engine->pttLatched());
REQUIRE(ptt->text().contains(QStringLiteral("\U0001F512")));
await([&]() { return engine->isTransmitting(); });
};
latchPtt();
engine->setHotkeyTalking(true);
engine->setHotkeyTalking(false);
REQUIRE(engine->isTalking()); // Отпускание хоткея не снимает фиксацию кнопки.
QTest::mousePress(ptt, Qt::LeftButton);
REQUIRE(!engine->isTalking()); // Следующий click отпускает уже на нажатии.
QTest::mouseRelease(ptt, Qt::LeftButton);
await([&]() { return !engine->isTransmitting(); });
latchPtt();
engine->setHotkeyTalking(true);
QTest::mouseClick(ptt, Qt::LeftButton);
REQUIRE(!engine->isTalking() && !engine->pttLatched()); // Общий unlock прекращает TX даже при ещё зажатом хоткее.
REQUIRE(ptt->text() == "PTT");
engine->setHotkeyTalking(false);
await([&]() { return !engine->isTransmitting(); });
latchPtt();
REQUIRE(engine->start(8) == 0);
await([&]() { return engine->captureAvailable(); });
REQUIRE(!engine->isTalking() && !engine->isTransmitting());
REQUIRE(engine->start(7) == 0);
await([&]() { return engine->captureAvailable(); });
QTest::mousePress(ptt, Qt::LeftButton);
REQUIRE(engine->isTalking()); // После смены канала снова работает обычное удержание.
QTest::mouseRelease(ptt, Qt::LeftButton);
REQUIRE(!engine->isTalking());
qInfo("radio panel: shared latch, lock indicator, click release, held hotkey cancellation and channel reset PASS");
if (app.arguments().contains("--ptt-hotkey")) {
auto* display = XOpenDisplay(nullptr);
REQUIRE(display);
QString code = QString::number(XKeysymToKeycode(display, XK_F8));
XCloseDisplay(display);
auto* hotkey = PttHotkeyManager::instance();
hotkey->init(nullptr);
hotkey->setHotkey(code);
REQUIRE(hotkey->hotkey() == code);
auto key = [&](bool pressed) {
REQUIRE(QProcess::execute("xdotool", {pressed ? "keydown" : "keyup", "F8"}) == 0);
pump(30);
};
auto lock = [&]() {
hotkey->setHotkey(code); // Новая пара не связана с предыдущим сценарием.
key(true); REQUIRE(engine->isTalking());
key(false); REQUIRE(!engine->isTalking());
key(true); key(false); REQUIRE(engine->isTalking() && engine->pttLatched());
REQUIRE(ptt->text().contains(QStringLiteral("\U0001F512")));
REQUIRE(ptt->toolTip() == "PTT locked. Click to release.");
};
key(true); REQUIRE(engine->isTalking());
key(true); key(false); REQUIRE(!engine->isTalking()); // Повтор keydown не фиксирует.
lock();
engine->talkBegin(); engine->talkEnd(); REQUIRE(engine->isTalking());
key(true); REQUIRE(!engine->isTalking()); // Нажатие снимает фиксацию сразу.
pump(40); REQUIRE(!engine->isTalking());
key(false); REQUIRE(!engine->isTalking());
REQUIRE(!engine->pttLatched() && ptt->text() == "PTT");
lock();
{
RadioPanel anotherPanel; // Новая панель сразу видит существующий общий замок.
REQUIRE(anotherPanel.findChild<QPushButton*>("radioPttButton")->text().contains(QStringLiteral("\U0001F512")));
}
REQUIRE(engine->isTalking() && engine->pttLatched());
QTest::mousePress(ptt, Qt::LeftButton);
REQUIRE(!engine->isTalking() && !engine->pttLatched() && ptt->text() == "PTT");
pump(50); REQUIRE(!engine->isTalking());
QTest::mouseRelease(ptt, Qt::LeftButton);
key(true); REQUIRE(engine->isTalking() && !engine->pttLatched()); // Unlock сбрасывает историю клавиатурной пары.
key(false); REQUIRE(!engine->isTalking());
hotkey->setHotkey(code);
key(true); key(false); key(true);
REQUIRE(engine->isTalking() && engine->pttLatched());
QTest::mouseClick(ptt, Qt::LeftButton); // Снимаем замок до отпускания второй клавиатурной кнопки.
pump(50); REQUIRE(!engine->isTalking() && !engine->pttLatched() && ptt->text() == "PTT");
key(false); REQUIRE(!engine->isTalking());
lock();
hotkey->setDoublePressEnabled(false);
REQUIRE(!engine->isTalking());
key(true); key(false); key(true); key(false);
REQUIRE(!engine->isTalking()); // Галочка выключена: только удержание.
latchPtt();
REQUIRE(engine->isTalking()); // Кнопочная фиксация доступна независимо от галочки.
key(true); REQUIRE(!engine->isTalking() && !engine->pttLatched() && ptt->text() == "PTT");
pump(50); REQUIRE(!engine->isTalking()); // Хоткей снимает мышиный замок даже при выключенной галочке.
key(false); REQUIRE(!engine->isTalking());
latchPtt();
hotkey->setDoublePressEnabled(true);
latchPtt();
key(true); REQUIRE(!engine->isTalking() && !engine->pttLatched());
key(false); REQUIRE(!engine->isTalking());
lock();
engine->stop();
REQUIRE(!engine->isTalking());
REQUIRE(engine->start(7) == 0);
await([&] { return engine->captureAvailable(); });
pump(40); REQUIRE(!engine->isTalking()); // Фиксация не переживает stop/start.
key(true); REQUIRE(engine->isTalking());
engine->stop();
REQUIRE(engine->start(7) == 0);
await([&] { return engine->captureAvailable(); });
pump(40); REQUIRE(!engine->isTalking()); // Удержание при stop требует нового нажатия.
key(false); key(true); REQUIRE(engine->isTalking());
key(false); REQUIRE(!engine->isTalking());
hotkey->setHotkey(QString());
qInfo("radio hotkey: X11 polling, shared lock, mouse/key unlock, lock indicator, checkbox and restart PASS");
}
REQUIRE(engine->applySettings({{"radio_vad_threshold", 65}, {"radio_auto_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->autoAvailable() && status->text() == "Automatic transmission unavailable");
QTest::mousePress(ptt, Qt::LeftButton);
await([&]() { return engine->isTransmitting(); });
REQUIRE(selfSpeaking());
QTest::mouseRelease(ptt, Qt::LeftButton);
await([&]() { return !engine->isTransmitting(); });
REQUIRE(!selfSpeaking());
qInfo("radio panel: capture-only restart preserves output/manual owner; VAD failure leaves manual PTT working PASS");
RadioSettingsPage settings(nullptr);
settings.resize(620, 780); settings.show();
auto* doublePress = settings.findChild<QCheckBox*>("radioPttDoublePress");
REQUIRE(settings.findChildren<QPushButton*>("radioPttShortcut").size() == 1);
REQUIRE(doublePress && doublePress->isChecked() && settings.doublePressEnabled() && settings.hotkey().isEmpty());
doublePress->setChecked(false);
REQUIRE(settings.apply() && !settings.doublePressEnabled());
await([&] { return !engine->settingsBusy(); });
latchPtt();
QTest::mouseClick(ptt, Qt::LeftButton);
REQUIRE(!engine->isTalking()); // Применённая галочка не меняет двойной клик кнопки.
doublePress->setChecked(true);
REQUIRE(settings.apply() && settings.doublePressEnabled());
await([&] { return !engine->settingsBusy(); });
auto* burst = settings.findChild<QSpinBox*>("radioBurstFrames");
REQUIRE(burst && burst->minimum() == 1 && burst->maximum() == 10 && burst->value() == 4);
oldInputs = inputs; oldOutputs = outputs;
burst->setValue(10); REQUIRE(settings.apply());
await([&]() { return !engine->settingsBusy(); });
REQUIRE(engine->setting("radio_burst_frames") == 10 && inputs == oldInputs && outputs == oldOutputs);
auto* mode = settings.findChild<QPushButton*>("radioModeLevel");
auto* on = settings.findChild<QSpinBox*>("radioLevelOn");
auto* off = settings.findChild<QSpinBox*>("radioLevelOff");
auto* levelText = settings.findChild<QLabel*>("radioLevelText");
REQUIRE(mode && on && off && levelText);
mode->click(); on->setValue(-50); off->setValue(-40);
REQUIRE(!settings.apply() && !engine->settingsBusy() && engine->autoMode() == 1);
off->setValue(-60);
REQUIRE(settings.apply());
await([&]() { return !engine->settingsBusy() && engine->autoAvailable() && engine->inputLevelValid(); });
REQUIRE(engine->autoMode() == 2 && !vad->icon().isNull() && vad->text().isEmpty() && radio_audio_auto_mode() == RADIO_AUTO_LEVEL);
REQUIRE(settings.findChild<QSpinBox*>("radioVadThreshold")->isEnabled() && on->isEnabled());
REQUIRE(!engine->isTransmitting() && status->text() == "Waiting for signal");
REQUIRE(levelText->text().contains("dBFS") && engine->inputLevel() == -120);
// Silero недоступен; уровень работает и сохраняется вместе с парой порогов.
REQUIRE(config.open(QIODevice::ReadOnly));
QByteArray levelConfig = config.readAll(); config.close();
REQUIRE(levelConfig.contains("radio_auto_mode=2") && levelConfig.contains("radio_level_on_dbfs=-50") &&
levelConfig.contains("radio_level_off_dbfs=-60"));
auto* parsedLevel = parse_config(configPath.toUtf8().constData());
REQUIRE(parsedLevel && chat_setting_get_int_state(&parsedLevel->global.chat_settings, "radio_auto_mode", 0) == 2 &&
chat_setting_get_int_state(&parsedLevel->global.chat_settings, "radio_level_on_dbfs", 0) == -50 &&
chat_setting_get_int_state(&parsedLevel->global.chat_settings, "radio_level_off_dbfs", 0) == -60);
free_config(parsedLevel);
REQUIRE(settings.grab().save("/tmp/desktop-radio-level-settings.png"));
vad->click();
await([&]() { return !engine->settingsBusy() && !engine->autoEnabled() && engine->inputLevelValid(); });
REQUIRE(!engine->autoAvailable() && levelText->text().contains("dBFS"));
engine->stop();
REQUIRE(!engine->inputLevelValid() && levelText->text().contains("Enable radio"));
settings.hide();
REQUIRE(engine->applySettings({{"radio_auto_enabled", 1}, {"radio_auto_mode", 1}}));
await([&]() { return !engine->settingsBusy(); });
qInfo("radio panel: level mode without Silero, live meter, inactive meter, threshold pair validation and saved settings PASS");
engine->stop();
REQUIRE(!engine->isTransmitting() && !engine->captureAvailable() && !vad->isEnabled());
REQUIRE(!selfSpeaking());
REQUIRE(config.open(QIODevice::ReadOnly));
QByteArray saved = config.readAll();
config.close();
REQUIRE(saved.contains("radio_auto_enabled=1") && saved.contains("radio_vad_threshold=65") &&
saved.contains("radio_auto_hangover_ms=400") && saved.contains("radio_vad_compressor_max_gain_db=30") &&
saved.contains("radio_opus_bitrate=32") && saved.contains("radio_burst_frames=10"));
// Как при рестарте приложения: runtime-настройки заново читаются парсером из сохранённого INI.
auto* parsed = parse_config(configPath.toUtf8().constData());
REQUIRE(parsed);
instance.chat_settings = parsed->global.chat_settings;
free_config(parsed);
engine->loadSettings(configPath);
await([&]() { return !engine->settingsBusy(); });
REQUIRE(engine->autoEnabled() && engine->setting("radio_vad_threshold") == 65 && engine->setting("radio_burst_frames") == 10);
detectorAvailable = true;
REQUIRE(engine->start(8) == 0);
await([&]() { return engine->autoAvailable(); });
REQUIRE(engine->groupId() == 8 && vad->isChecked());
engine->stop();
failCapture = true;
REQUIRE(engine->start(9) == 0);
REQUIRE(!engine->captureAvailable() && vad->isEnabled() && !ptt->isEnabled());
vad->click();
await([&]() { return !engine->settingsBusy() && !engine->autoEnabled(); });
REQUIRE(!engine->captureAvailable());
failCapture = false;
await([&]() { return engine->captureAvailable(); });
vad->click();
await([&]() { return !engine->settingsBusy() && engine->autoAvailable(); });
REQUIRE(vad->isChecked() && ptt->isEnabled());
qInfo("radio panel: unavailable microphone permits VAD settings; capture recovery restores controls PASS");
engine->stop();
engine->loadSettings(directory.filePath("missing.cfg"));
await([&]() { return !engine->settingsBusy(); });
REQUIRE(!engine->applySettings({{"radio_auto_enabled", 0}}) && engine->autoEnabled() && !engine->settingsError().isEmpty());
for (auto* button : panel.findChildren<QToolButton*>()) {
REQUIRE(QMetaObject::invokeMethod(button->menu(), "aboutToShow", Qt::DirectConnection));
REQUIRE(!button->menu()->actions().isEmpty());
}
engine->loadSettings(configPath);
await([&]() { return !engine->settingsBusy(); });
REQUIRE(engine->start(10) == 0 && engine->autoAvailable());
pump(100);
REQUIRE(panel.grab().save("/tmp/desktop-radio-panel.png"));
engine->stop();
radio_audio_destroy(&instance);
uasync_destroy(instance.ua, 0);
instance.ua = nullptr;
manager->shutdown();
size_t allocated = u_get_allocated_count();
instance.ua = uasync_create();
REQUIRE(instance.ua);
engine->loadSettings(configPath);
REQUIRE(engine->settingsBusy() && instance.ua->posted_tasks_head);
uasync_destroy(instance.ua, 0);
instance.ua = nullptr;
REQUIRE(u_get_allocated_count() == allocated);
qInfo("radio panel: pending settings request freed on core shutdown PASS");
// Повторная загрузка после отмены старой задачи должна поставить запрос в новое ядро.
debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_DEBUG);
instance.ua = uasync_create();
REQUIRE(instance.ua && chat_setting_set(&instance, "radio_vad_threshold", "75") == 0);
engine->loadSettings(configPath);
REQUIRE(engine->settingsBusy() && !engine->settingsReady() && instance.ua->posted_tasks_head);
await([&] { return engine->settingsReady() && !engine->settingsBusy(); });
REQUIRE(engine->setting("radio_vad_threshold") == 75);
uasync_destroy(instance.ua, 0); instance.ua = nullptr;
REQUIRE(u_get_allocated_count() == allocated);
qInfo("radio panel: settings reload after cancelled core request PASS");
// Старый uasync уже закончил чтение, но GUI completion ждёт доставки при запуске нового ядра.
instance.ua = uasync_create();
REQUIRE(instance.ua && chat_setting_set(&instance, "radio_vad_threshold", "20") == 0);
engine->loadSettings(configPath);
uasync_poll(instance.ua, 0);
REQUIRE(engine->settingsBusy() && !instance.ua->posted_tasks_head);
uasync_destroy(instance.ua, 0); instance.ua = nullptr;
instance.ua = uasync_create();
REQUIRE(instance.ua && chat_setting_set(&instance, "radio_vad_threshold", "80") == 0);
engine->loadSettings(configPath);
REQUIRE(engine->settingsBusy() && !engine->settingsReady() && instance.ua->posted_tasks_head);
QSignalSpy staleSettings(engine, &RadioAudioEngine::stateChanged);
QCoreApplication::sendPostedEvents(engine, QEvent::MetaCall);
REQUIRE(engine->settingsBusy() && !engine->settingsReady() && engine->setting("radio_vad_threshold") == 75);
REQUIRE(staleSettings.isEmpty() && instance.ua->posted_tasks_head);
await([&] { return engine->settingsReady() && !engine->settingsBusy(); });
REQUIRE(engine->setting("radio_vad_threshold") == 80 && staleSettings.size() == 1);
uasync_destroy(instance.ua, 0); instance.ua = nullptr;
REQUIRE(u_get_allocated_count() == allocated);
qInfo("radio panel: stale GUI settings completion cannot unlock or overwrite restarted core PASS");
// Перечитывание из stateChanged сохраняет порядок: принятая запись должна предшествовать новому чтению.
instance.ua = uasync_create();
REQUIRE(instance.ua && chat_setting_set(&instance, "radio_vad_threshold", "85") == 0);
engine->loadSettings(configPath);
await([&] { return engine->settingsReady() && !engine->settingsBusy(); });
REQUIRE(engine->setting("radio_vad_threshold") == 85);
bool reloaded = false;
auto reload = QObject::connect(engine, &RadioAudioEngine::stateChanged, engine, [&] {
if (reloaded || !engine->settingsBusy()) return;
reloaded = true;
engine->loadSettings(configPath);
});
REQUIRE(engine->applySettings({{"radio_vad_threshold", 55}}) && reloaded);
QObject::disconnect(reload);
QSignalSpy appliedSettings(engine, &RadioAudioEngine::stateChanged);
await([&] { return engine->settingsReady() && !engine->settingsBusy(); });
REQUIRE(engine->setting("radio_vad_threshold") == 55 && appliedSettings.size() == 1);
uasync_destroy(instance.ua, 0); instance.ua = nullptr;
REQUIRE(u_get_allocated_count() == allocated);
qInfo("radio panel: settings reload from stateChanged preserves accepted apply order PASS");
qInfo("radio panel: saved settings, channel change, device menus, range validation and config failure PASS");
return 0;
}