/* Реальные страницы/диалог/MainWindow/bridge/SQLite: запросы переживают форму, освобождаются при отказе/отмене/OOM. */ #include "../src/storagesettingspage.h" #include "../src/audiodevicesettingspage.h" #include "../src/settingsdialog.h" #include "../src/mainwindow.h" #include "../src/radio_audio_engine.h" #include "../src/voicemessageencoder.h" #include "../db/db_manager.h" #include "../transport/gui_bridge.h" #include #include #include #include #include #include #include #include #include #include #include extern "C" { #include "utun_instance.h" #include "chat/chat_core.h" #include "chat/chat_setting.h" #include "mem.h" #include "debug_config.h" } #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES #include "miniaudio.h" static bool watching, failMalloc; static int failAllocation; static std::map outstanding; static bool commandSource(const char* where) { return strstr(where, "/gui_bridge_impl.cpp:") || strstr(where, "/settingsdialog.cpp:") || strstr(where, "/storagesettingspage.cpp:") || strstr(where, "/audiodevicesettingspage.cpp:") || strstr(where, "/mainwindow.cpp:"); } static bool rejectAllocation(const char* where, bool mallocCall) { if (!failAllocation || mallocCall != failMalloc || !strstr(where, "/gui_bridge_impl.cpp:") || --failAllocation) return false; DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "[INJECT] settings allocation failure malloc=%d at %s", mallocCall, where); return true; } extern "C" void* __real_u_malloc_impl(uint32_t, const char*); extern "C" void* __wrap_u_malloc_impl(uint32_t bytes, const char* where) { if (rejectAllocation(where, true)) return nullptr; void* ptr = __real_u_malloc_impl(bytes, where); if (ptr && watching && commandSource(where)) outstanding.emplace(ptr, where); return ptr; } extern "C" void* __real_u_calloc_impl(uint32_t, uint32_t, const char*); extern "C" void* __wrap_u_calloc_impl(uint32_t count, uint32_t bytes, const char* where) { if (rejectAllocation(where, false)) return nullptr; void* ptr = __real_u_calloc_impl(count, bytes, where); if (ptr && watching && commandSource(where)) outstanding.emplace(ptr, where); return ptr; } extern "C" void __real_u_free_impl(void*, const char*); extern "C" void __wrap_u_free_impl(void* ptr, const char* where) { if (watching) outstanding.erase(ptr); __real_u_free_impl(ptr, where); } // Настоящий mixer работает через null backend: тест закрытия окна не зависит от аппаратных аудиоустройств. extern "C" ma_result __real_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context*); extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config* config, ma_context* context) { const ma_backend backend = ma_backend_null; return __real_ma_context_init(&backend, 1, config, context); } #endif struct SettingsCore { QTemporaryDir directory; UASYNC* ua = uasync_create(); UTUN_INSTANCE* inst = nullptr; DbManager db; QString path() const { return directory.filePath("utun.cfg"); } ~SettingsCore() { gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr); if (ua) uasync_mark_stopped(ua); db.setDb(nullptr); if (inst) utun_instance_destroy(inst); if (ua) uasync_destroy(ua, 0); } bool start() { if (!ua || !directory.isValid()) return false; const QByteArray text = "[global]\n" "my_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" "my_node_name=Before\n[chatserver]\ngroup_autoconnect=0\nmember_files_enabled=0\nstorage_autoload=0\n"; QFile file(path()); if (!file.open(QIODevice::WriteOnly) || file.write(text) != text.size()) return false; file.close(); inst = utun_instance_create_from_str(ua, text.constData()); if (!inst || utun_core_start(inst) || chat_service_start(inst)) return false; db.setDb(inst->topo_sqlite_db); db.setMyNodeId(inst->node_id); gui_bridge_set_uasync(ua); gui_bridge_set_inst(inst); RadioAudioEngine::instance()->loadSettings(path()); uasync_poll(ua, 0); QCoreApplication::processEvents(); return RadioAudioEngine::instance()->settingsReady(); } }; class SettingsSaveTest : public QObject { Q_OBJECT enum Page { Storage, Audio, Dialog }; enum WindowOutcome { Unavailable, Cancelled, Executed, Destroyed }; std::unique_ptr createPage(SettingsCore& core, int kind) { std::unique_ptr result; if (kind == Storage) { auto* page = new StorageSettingsPage(&core.db); page->setConfigPath(core.path()); page->loadFromDb(); result.reset(page); } else if (kind == Audio) { auto* page = new AudioDeviceSettingsPage(&core.db, nullptr); page->setConfigPath(core.path()); page->loadFromDb(); result.reset(page); } else { auto* page = new SettingsDialog(core.path(), &core.db, nullptr, nullptr); for (auto* edit : page->findChildren()) if (edit->text() == "Before") edit->setText("After"); page->findChild("streamSubblocks")->setValue(12); page->findChild("streamRedundant")->setValue(3); result.reset(page); } auto* storage = kind == Storage ? static_cast(result.get()) : result->findChild(); if (storage) { storage->findChild()->setChecked(true); const auto spins = storage->findChildren(); spins[0]->setValue(17); spins[1]->setValue(3); } auto* audio = kind == Audio ? static_cast(result.get()) : result->findChild(); if (audio) { const auto combos = audio->findChildren(); combos[2]->setCurrentIndex(combos[2]->findData(2)); } return result; } void apply(QWidget* page, int kind) { if (kind == Storage) static_cast(page)->applyAndSave(); else if (kind == Audio) static_cast(page)->applyAndSave(); else QVERIFY(QMetaObject::invokeMethod(page, "onSave", Qt::DirectConnection)); } void checkSaved(SettingsCore& core, int kind) { if (kind != Audio) { QCOMPARE(core.db.getUiStateInt("storage_autoload", -1), 1); QCOMPARE(core.db.getUiStateInt("storage_autoload_maxsize_mb", -1), 17); QCOMPARE(core.db.getUiStateInt("storage_maxsize_gb", -1), 3); QCOMPARE(chat_setting_get_int(core.inst, "storage_autoload", -1), 1); QSettings config(core.path(), QSettings::IniFormat); QCOMPARE(config.value("chatserver/storage_unit_size").toString(), QString("17M")); QCOMPARE(config.value("chatserver/storage_total_size").toString(), QString("3G")); } if (kind != Storage) { QCOMPARE(core.db.getUiStateInt("opus_codec_preset", -1), 2); QCOMPARE(chat_setting_get_int(core.inst, "opus_codec_preset", -1), 2); QVERIFY(!core.db.getUiState("call_video_bitrate_range_kbps").isEmpty()); QCOMPARE(core.db.getUiState("call_video_bitrate_range_kbps"), QString::fromUtf8(chat_setting_get(core.inst, "call_video_bitrate_range_kbps"))); QCOMPARE(VoiceEncoder::preset(), 2); } if (kind == Dialog) { QCOMPARE(QString::fromUtf8(core.inst->name), QString("After")); QCOMPARE(chat_setting_get_int(core.inst, "stream_subblocks", -1), 12); QCOMPARE(chat_setting_get_int(core.inst, "stream_rs_redundant", -1), 3); QCOMPARE(core.db.getUiStateInt("join_policy", -1), 1); QCOMPARE(core.db.getUiStateInt("group_autoconnect", -1), 1); } } private slots: void initTestCase() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); gui_bridge_init(); debug_set_category_level(DEBUG_CATEGORY_CONFIG, DEBUG_LEVEL_DEBUG); debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); auto* warnings = new QTimer(this); connect(warnings, &QTimer::timeout, [] { if (auto* box = qobject_cast(QApplication::activeModalWidget())) box->accept(); }); warnings->start(10); } void init() { #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES watching = false; outstanding.clear(); failAllocation = 0; #endif } void cleanup() { #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES watching = false; failAllocation = 0; outstanding.clear(); #endif } void lifecycle_data() { QTest::addColumn("kind"); QTest::addColumn("outcome"); for (int kind : {Storage, Audio, Dialog}) for (int outcome = 0; outcome < 3; ++outcome) QTest::newRow(qPrintable(QString("%1-%2").arg(kind).arg(outcome))) << kind << outcome; } void lifecycle() { QFETCH(int, kind); QFETCH(int, outcome); SettingsCore core; QVERIFY(core.start()); auto page = createPage(core, kind); if (!outcome) gui_bridge_set_inst(nullptr); // Отказ при недоступном ядре. const auto baseline = u_get_allocated_count(); #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES watching = true; #endif apply(page.get(), kind); page.reset(); // Снимок запросов не должен зависеть от формы. if (outcome == 2) { uasync_poll(core.ua, 0); checkSaved(core, kind); } else { gui_bridge_set_inst(nullptr); QCOMPARE(core.db.getUiStateInt("storage_autoload", -1), -1); QCOMPARE(core.db.getUiStateInt("opus_codec_preset", -1), -1); QCOMPARE(u_get_allocated_count(), baseline); } #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES QCOMPARE(outstanding.size(), size_t(0)); #endif } void windowGeometry_data() { QTest::addColumn("outcome"); QTest::addColumn("payload"); QTest::addColumn("position"); QTest::newRow("unavailable") << int(Unavailable) << false << 0; QTest::newRow("cancelled") << int(Cancelled) << false << 0; QTest::newRow("executed") << int(Executed) << false << 0; QTest::newRow("window-destroyed-before-execution") << int(Destroyed) << false << 0; #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES for (bool payload : {false, true}) for (int position : {1, 2, 3}) for (int outcome : {int(Cancelled), int(Executed)}) QTest::newRow(qPrintable(QString("oom-%1-%2-%3").arg(payload ? "payload" : "queue").arg(position).arg(outcome))) << outcome << payload << position; #endif } void windowGeometry() { QFETCH(int, outcome); QFETCH(bool, payload); QFETCH(int, position); SettingsCore core; QVERIFY(core.start()); // Ядро принадлежит fixture, nullptr db отключает создание фонового UtunNode в настоящем окне. auto window = std::make_unique(nullptr, nullptr, QString()); window->show(); QCoreApplication::processEvents(); auto* splitter = qobject_cast(window->centralWidget()); QVERIFY(splitter); QCOMPARE(splitter->sizes().size(), 3); const QRect geometry = window->normalGeometry(); QVERIFY(!geometry.isEmpty()); const auto sizes = splitter->sizes(); const QStringList keys{"window_geometry", "window_maximized", "window_splitter"}; const QStringList values{ QStringLiteral("%1,%2,%3,%4").arg(geometry.width()).arg(geometry.height()).arg(geometry.x()).arg(geometry.y()), window->isMaximized() ? QStringLiteral("1") : QStringLiteral("0"), QStringLiteral("%1,%2,%3").arg(sizes[0]).arg(sizes[1]).arg(sizes[2])}; if (outcome == Unavailable) gui_bridge_set_inst(nullptr); QVERIFY(!core.ua->posted_tasks_head); const auto baseline = u_get_allocated_count(); #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES watching = true; failMalloc = payload; failAllocation = position; #endif QVERIFY(!window->close()); // closeEvent сохраняет настройки и скрывает окно в tray. QVERIFY(!window->isVisible()); int queued = 0; for (auto* task = core.ua->posted_tasks_head; task; task = task->next) ++queued; QCOMPARE(queued, outcome == Unavailable ? 0 : position ? 2 : 3); window->resize(1100, 700); // В очереди должен остаться снимок исходной геометрии. if (outcome == Destroyed) window.reset(); if (outcome == Executed || outcome == Destroyed) uasync_poll(core.ua, 0); else gui_bridge_set_inst(nullptr); QVERIFY(!core.ua->posted_tasks_head); for (int i = 0; i < keys.size(); ++i) { const bool saved = (outcome == Executed || outcome == Destroyed) && i + 1 != position; QCOMPARE(core.db.getUiState(keys[i]), saved ? values[i] : QString()); } // poll может расширить пул таймеров uasync; владение командами проверяется отдельно по местам выделения. if (outcome == Unavailable || outcome == Cancelled) QCOMPARE(u_get_allocated_count(), baseline); #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES QCOMPARE(failAllocation, 0); for (const auto& item : outstanding) DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "Unfreed UI setting command ptr=%p source=%s", item.first, item.second); QCOMPARE(outstanding.size(), size_t(0)); #endif DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "Window geometry verified: outcome=%d failed_setting=%d payload=%d queued=%d", outcome, position, payload, queued); } void pairLimits_data() { QTest::addColumn("target"); QTest::addColumn("key"); QTest::addColumn("value"); QTest::addColumn("accepted"); for (int target : {int(GUI_SETTING_UI_STATE), int(GUI_SETTING_CHAT)}) { const int capacity = target == GUI_SETTING_UI_STATE ? sizeof(save_ui_state_arg::data) : sizeof(chat_setting_arg::data); const QByteArray prefix = QByteArray::number(target) + '-'; QTest::newRow((prefix + "null-key").constData()) << target << QByteArray() << QByteArray("v") << false; QTest::newRow((prefix + "empty-key").constData()) << target << QByteArray("") << QByteArray("v") << false; QTest::newRow((prefix + "null-value").constData()) << target << QByteArray("k") << QByteArray() << false; QTest::newRow((prefix + "empty-value").constData()) << target << QByteArray("k") << QByteArray("") << true; QTest::newRow((prefix + "exact-fit").constData()) << target << QByteArray("k") << QByteArray(capacity - 3, 'v') << true; QTest::newRow((prefix + "long-value").constData()) << target << QByteArray("k") << QByteArray(capacity - 2, 'v') << false; QTest::newRow((prefix + "long-key").constData()) << target << QByteArray(capacity, 'k') << QByteArray("") << false; } } void pairLimits() { QFETCH(int, target); QFETCH(QByteArray, key); QFETCH(QByteArray, value); QFETCH(bool, accepted); SettingsCore core; QVERIFY(core.start()); const auto baseline = u_get_allocated_count(); QCOMPARE(gui_bridge_post_setting(static_cast(target), key.isNull() ? nullptr : key.constData(), value.isNull() ? nullptr : value.constData()), accepted ? 0 : -1); gui_bridge_set_inst(nullptr); QCOMPARE(u_get_allocated_count(), baseline); } void nameLimits_data() { QTest::addColumn("name"); QTest::addColumn("accepted"); QTest::newRow("null") << QByteArray() << false; QTest::newRow("empty") << QByteArray("") << true; QTest::newRow("exact-fit") << QByteArray(sizeof(update_my_name_arg::name) - 1, 'n') << true; QTest::newRow("too-long") << QByteArray(sizeof(update_my_name_arg::name), 'n') << false; } void nameLimits() { QFETCH(QByteArray, name); QFETCH(bool, accepted); SettingsCore core; QVERIFY(core.start()); const auto baseline = u_get_allocated_count(); QCOMPARE(gui_bridge_update_name(name.isNull() ? nullptr : name.constData()), accepted ? 0 : -1); gui_bridge_set_inst(nullptr); QCOMPARE(u_get_allocated_count(), baseline); } void copiesInputsBeforeReturning() { SettingsCore core; QVERIFY(core.start()); #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES watching = true; #endif QByteArray key("snapshot"), value("before"), name("After"), setting("storage_autoload"), enabled("1"); QCOMPARE(gui_bridge_post_setting(GUI_SETTING_UI_STATE, key.constData(), value.constData()), 0); QCOMPARE(gui_bridge_post_setting(GUI_SETTING_CHAT, setting.constData(), enabled.constData()), 0); QCOMPARE(gui_bridge_update_name(name.constData()), 0); key.fill('x'); value.fill('x'); name.fill('x'); setting.fill('x'); enabled.fill('0'); uasync_poll(core.ua, 0); QCOMPARE(core.db.getUiState("snapshot"), QString("before")); QCOMPARE(chat_setting_get_int(core.inst, "storage_autoload", -1), 1); QCOMPARE(QString::fromUtf8(core.inst->name), QString("After")); #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES QCOMPARE(outstanding.size(), size_t(0)); #endif } #ifdef UTUN_TEST_SETTINGS_ALLOC_FAILURES void allocationFailure_data() { QTest::addColumn("kind"); QTest::addColumn("payload"); QTest::addColumn("position"); QTest::addColumn("cancel"); for (int kind : {Storage, Audio, Dialog}) { for (bool payload : {false, true}) { const int count = kind == Storage ? 4 : kind == Audio ? 22 : payload ? 38 : 56; QList positions{1, 2, count}; if (count / 2 > 2) positions.append(count / 2); for (int position : positions) for (bool cancel : {false, true}) QTest::newRow(qPrintable(QString("%1-%2-%3-%4").arg(kind).arg(payload).arg(position).arg(cancel))) << kind << payload << position << cancel; } } } void allocationFailure() { QFETCH(int, kind); QFETCH(bool, payload); QFETCH(int, position); QFETCH(bool, cancel); SettingsCore core; QVERIFY(core.start()); auto page = createPage(core, kind); watching = true; failMalloc = payload; failAllocation = position; apply(page.get(), kind); QCOMPARE(failAllocation, 0); if (cancel) gui_bridge_set_inst(nullptr); else uasync_poll(core.ua, 0); QCOMPARE(outstanding.size(), size_t(0)); } void nameAllocationFailure_data() { QTest::addColumn("payload"); QTest::newRow("payload") << true; QTest::newRow("queue") << false; } void nameAllocationFailure() { QFETCH(bool, payload); SettingsCore core; QVERIFY(core.start()); watching = true; failMalloc = payload; failAllocation = 1; QCOMPARE(gui_bridge_update_name("After"), -1); QCOMPARE(failAllocation, 0); QCOMPARE(outstanding.size(), size_t(0)); QVERIFY(!core.ua->posted_tasks_head); } #endif }; QTEST_MAIN(SettingsSaveTest) #include "test_settings_save.moc"