/* Desktop MainWindow/MessageList/delegate/SQLite/bridge: отказы, отмена, OOM и привязка к владельцу. * Native trampoline исполняется без подмены; пустой каталог CHAT даёт штатный FINISHED/UNAVAILABLE для START. */ #include "../src/mainwindow.h" #include "../transport/gui_bridge.h" #include "../transport/utun_node.h" #include "../db/db_manager.h" #include "../src/inputbar.h" #include "../src/messagedelegate.h" #include "../src/messagelist.h" #include "../src/sound_manager.h" #include #include #include #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_event.h" #include "radio/radio_call.h" #include "dm/dm_core.h" #include "media_async/attachment_send.h" #include "audio_compressor.h" #include "mem.h" #include "debug_config.h" } static UTUN_INSTANCE* executingCore = nullptr; static bool bindingValid = true; static QList observed; static QMap submissions; #ifdef UTUN_TEST_DESKTOP_HOOKS #include "miniaudio.h" static std::atomic watching{false}; static std::atomic failAllocation{0}; static std::mutex allocationMutex; static std::map outstanding; static void track(void* pointer, const char* source) { if (!pointer || !watching || (!strstr(source, "/gui_bridge_impl.cpp:") && !strstr(source, "/mainwindow.cpp:") && !strstr(source, "/messagelist.cpp:") && !strstr(source, "/messagefeedback.cpp:") && !strstr(source, "/test_desktop_commands.cpp:"))) return; std::lock_guard lock(allocationMutex); outstanding.emplace(pointer, source); } extern "C" void* __real_u_malloc_impl(uint32_t, const char*); extern "C" void* __wrap_u_malloc_impl(uint32_t bytes, const char* source) { void* pointer = __real_u_malloc_impl(bytes, source); track(pointer, source); return pointer; } 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* source) { if (failAllocation && strstr(source, "/gui_bridge_impl.cpp:") && --failAllocation == 0) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "[INJECT] radio command allocation failure at %s", source); return nullptr; } void* pointer = __real_u_calloc_impl(count, bytes, source); track(pointer, source); return pointer; } extern "C" void __real_u_free_impl(void*, const char*); extern "C" void __wrap_u_free_impl(void* pointer, const char* source) { { std::lock_guard lock(allocationMutex); outstanding.erase(pointer); } __real_u_free_impl(pointer, source); } extern "C" void __real_radio_call_trampoline(void*); extern "C" void __wrap_radio_call_trampoline(void* arg) { auto* request = static_cast(arg); if (request->inst != executingCore) { bindingValid = false; DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Radio command binding mismatch id=%llu request_inst=%p executing_core=%p", (unsigned long long)request->id, static_cast(request->inst), static_cast(executingCore)); } observed.append(*request); __real_radio_call_trampoline(arg); } // Настоящий 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); } // Инъекции сохраняют настоящий finalize, чтобы даже ошибочный результат не оставлял statement. static const char* sqlMatch = nullptr; static int sqlStage = 0, sqlCalls = 0; static bool failSql(sqlite3_stmt* statement, int stage) { const char* sql = sqlite3_sql(statement); if (sqlStage != stage || !sqlMatch || !sql || !strstr(sql, sqlMatch) || --sqlCalls) return false; sqlStage = 0; DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "[INJECT] SQLite stage=%d sql=%s", stage, sql); return true; } extern "C" int __real_sqlite3_step(sqlite3_stmt*); extern "C" int __wrap_sqlite3_step(sqlite3_stmt* statement) { return failSql(statement, 1) ? SQLITE_IOERR : __real_sqlite3_step(statement); } extern "C" int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void(*)(void*)); extern "C" int __wrap_sqlite3_bind_text(sqlite3_stmt* statement, int index, const char* text, int bytes, void(*destroy)(void*)) { return failSql(statement, 2) ? SQLITE_NOMEM : __real_sqlite3_bind_text(statement, index, text, bytes, destroy); } extern "C" int __real_sqlite3_finalize(sqlite3_stmt*); extern "C" int __wrap_sqlite3_finalize(sqlite3_stmt* statement) { const bool fail = failSql(statement, 3); const int rc = __real_sqlite3_finalize(statement); return fail ? SQLITE_IOERR : rc; } #endif struct DesktopCore { QTemporaryDir directory; UASYNC* ua = uasync_create(); UTUN_INSTANCE* inst = nullptr; unsigned results = 0; bool resultValid = true; ~DesktopCore() { gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr); if (ua) uasync_mark_stopped(ua); if (inst) utun_instance_destroy(inst); if (ua) uasync_destroy(ua, 0); } bool start() { if (!ua || !directory.isValid()) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "fixture: no uasync/config directory"); return false; } const QByteArray config = "[global]\nauto_sockets=no\ndb_path=" + directory.path().toUtf8() + "\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n" "member_files_enabled=0\nlocal_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n"; const QString path = directory.filePath("utun.cfg"); QFile file(path); if (!file.open(QIODevice::WriteOnly) || file.write(config) != config.size()) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "fixture: config write failed: %s", qPrintable(file.errorString())); return false; } file.close(); inst = utun_instance_create(ua, path.toUtf8().constData()); if (!inst || utun_core_start(inst) || chat_service_start(inst)) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "fixture: core/chat startup failed"); return false; } inst->test_user_ptr = this; chat_event_set_handler(inst, [](UTUN_INSTANCE* instance, int type, const uint8_t* data, int len) { if (type == CHAT_EVT_MSG_SUBMITTED) gui_bridge_post(type, data, len); if (type != CHAT_EVT_RADIO_CALL) return; auto* core = static_cast(instance->test_user_ptr); ++core->results; core->resultValid &= len == 27 && data[24] == RADIO_CALL_FINISHED && data[25] == RADIO_CALL_UNAVAILABLE && !data[26]; }); attach(); poll(); return true; } void attach() { uasync_mark_running(ua); gui_bridge_set_uasync(ua); gui_bridge_set_inst(inst); } void poll() { executingCore = inst; uasync_poll(ua, 0); executingCore = nullptr; } }; static unsigned pending(UASYNC* ua) { unsigned count = 0; for (auto* task = ua->posted_tasks_head; task; task = task->next) ++count; return count; } class DesktopCommandsTest : public QObject { Q_OBJECT private slots: void initTestCase() { qApp->setQuitOnLastWindowClosed(false); debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); gui_bridge_init(); debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_DEBUG); debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); } void init() { observed.clear(); submissions.clear(); bindingValid = true; gui_bridge_set_msg_submitted_cb([](const uint8_t* data, int len) { if (len != 9) return; quint64 id = 0; for (int i = 0; i < 8; ++i) id |= quint64(data[i]) << (8 * i); submissions.insert(id, data[8] != 0); }); #ifdef UTUN_TEST_DESKTOP_HOOKS watching = false; failAllocation = 0; sqlMatch = nullptr; sqlStage = 0; sqlCalls = 0; #endif } void cleanup() { #ifdef UTUN_TEST_DESKTOP_HOOKS failAllocation = 0; sqlStage = 0; sqlMatch = nullptr; #endif gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr); QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); QCoreApplication::processEvents(); gui_bridge_set_msg_submitted_cb(nullptr); #ifdef UTUN_TEST_DESKTOP_HOOKS watching = false; std::lock_guard lock(allocationMutex); for (const auto& item : outstanding) DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Unfreed radio command ptr=%p source=%s", item.first, item.second); QCOMPARE(outstanding.size(), size_t(0)); #endif } void imagePreviewPreparedOutsidePaint() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString path = directory.filePath("image.png"); QImage original(1200, 1800, QImage::Format_ARGB32); original.fill(Qt::red); QVERIFY(original.save(path)); MessageList history; history.resize(600, 500); history.show(); auto* model = history.findChild(); QVERIFY(model); auto* view = history.findChild(); QVERIFY(view); auto* item = new QStandardItem; item->setData("image/png", MsgContentTypeRole); item->setData(path, MsgVoiceFileRole); item->setData(1200, MsgImageWidthRole); item->setData(1800, MsgImageHeightRole); item->setData(2, MsgFileDownloadStateRole); model->appendRow(item); QTRY_VERIFY_WITH_TIMEOUT(!item->data(MsgImagePreviewRole).value().isNull(), 3000); const QImage preview = item->data(MsgImagePreviewRole).value(); QVERIFY(preview.width() <= 600 && preview.height() <= 600); QCOMPARE(preview.width() * 3, preview.height() * 2); QVERIFY(!item->data(MsgImageErrorRole).toBool()); QVERIFY(QFile::remove(path)); // Paint обязан использовать подготовленное превью даже без файла на диске. QImage canvas(600, 400, QImage::Format_ARGB32); canvas.fill(Qt::black); QStyleOptionViewItem option; option.initFrom(view); option.rect = canvas.rect(); QPainter painter(&canvas); view->itemDelegate()->paint(&painter, option, model->index(0, 0)); painter.end(); int red = 0; for (int y = 0; y < canvas.height(); ++y) for (int x = 0; x < canvas.width(); ++x) { const QColor color = canvas.pixelColor(x, y); if (color.red() > 220 && color.green() < 30 && color.blue() < 30) ++red; } QVERIFY(red > 1000); } void boundCommand_data() { QTest::addColumn("scenario"); for (const auto& name : {"unavailable", "stopped", "cancelled", "executed"}) QTest::newRow(name) << QString(name); #ifdef UTUN_TEST_DESKTOP_HOOKS QTest::newRow("queue-oom") << QString("queue-oom"); #endif } void boundCommand() { QFETCH(QString, scenario); DesktopCore core; QVERIFY(core.start()); if (scenario == "unavailable") gui_bridge_set_inst(nullptr); if (scenario == "stopped") uasync_mark_stopped(core.ua); #ifdef UTUN_TEST_DESKTOP_HOOKS watching = true; failAllocation = scenario == "queue-oom" ? 1 : 0; #endif int executed = 0; bool valid = false; struct Command { UTUN_INSTANCE* inst; int* executed; bool* valid; }; auto* request = static_cast(u_calloc(1, sizeof(Command))); QVERIFY(request); request->executed = &executed; request->valid = &valid; const int rc = gui_bridge_post_bound_fn([](void* arg) { auto* command = static_cast(arg); ++*command->executed; *command->valid = command->inst && command->inst == executingCore; u_free(command); }, request, &request->inst); const bool accepted = scenario == "executed" || scenario == "cancelled"; QCOMPARE(rc, accepted ? 0 : -1); if (rc) { QVERIFY(!request->inst); u_free(request); } if (scenario == "executed") core.poll(); else gui_bridge_set_inst(nullptr); QCOMPARE(executed, scenario == "executed" ? 1 : 0); if (executed) QVERIFY(valid); QCOMPARE(pending(core.ua), 0u); } void attachmentOwnership_data() { QTest::addColumn("scenario"); QTest::addColumn("temporary"); QStringList names{"unavailable", "stopped", "cancelled", "executed"}; #ifdef UTUN_TEST_DESKTOP_HOOKS names << "queue-oom"; #endif for (const auto& name : names) for (bool temporary : {false, true}) QTest::newRow(qPrintable(name + (temporary ? "-owned-source" : "-borrowed-source"))) << name << temporary; } void attachmentOwnership() { QFETCH(QString, scenario); QFETCH(bool, temporary); std::atomic releases{0}; DesktopCore core; QVERIFY(core.start()); const quint64 channel = chat_core_create_channel_auto(core.inst, "Attachment ownership"); QVERIFY(channel); core.poll(); const QByteArray source = core.directory.filePath("recording.raw").toUtf8(); QFile file(QString::fromUtf8(source)); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write("source"), qint64(6)); file.close(); #ifdef UTUN_TEST_DESKTOP_HOOKS watching = true; #endif struct PcmOwner { std::atomic* releases; int16_t samples[24000]; }; auto* owner = static_cast(u_calloc(1, sizeof(PcmOwner))); QVERIFY(owner); owner->releases = &releases; auto* request = static_cast(u_calloc(1, sizeof(attachment_send_req))); QVERIFY(request); request->pcm = owner->samples; request->pcm_count = 24000; request->pcm_owner = owner; request->pcm_release = [](void* arg) { auto* pcm = static_cast(arg); ++*pcm->releases; u_free(pcm); }; request->info.kind = ATTACHMENT_VOICE; request->request_id = 77; qstrncpy(request->target, qPrintable(QString::number(channel)), sizeof(request->target)); qstrncpy(request->source, source.constData(), sizeof(request->source)); request->temporary_source = temporary; if (scenario == "unavailable") gui_bridge_set_inst(nullptr); if (scenario == "stopped") uasync_mark_stopped(core.ua); #ifdef UTUN_TEST_DESKTOP_HOOKS failAllocation = scenario == "queue-oom" ? 1 : 0; #endif const bool accepted = scenario == "executed" || scenario == "cancelled"; const int rc = gui_bridge_submit_attachment(request); QCOMPARE(rc, accepted ? 0 : -1); if (rc) attachment_send_discard(request); if (scenario == "executed") { QTRY_VERIFY_WITH_TIMEOUT(([&] { core.poll(); return submissions.contains(77); })(), 5000); QVERIFY(submissions.value(77)); DbManager db; db.setDb(chat_core_get_db(core.inst)); db.setMyNodeId(core.inst->node_id); const auto messages = db.getMessages(QString::number(channel), 1); QCOMPARE(messages.size(), 1); QCOMPARE(messages.first().contentType, QString("audio/opus")); } else { gui_bridge_set_inst(nullptr); QCoreApplication::processEvents(); QCOMPARE(submissions.contains(77), scenario == "cancelled"); if (submissions.contains(77)) QVERIFY(!submissions.value(77)); } QCOMPARE(releases.load(), 1); QCOMPARE(QFile::exists(QString::fromUtf8(source)), !temporary); } void discardUnsubmittedCompressor() { const auto baseline = u_get_allocated_count(); auto* request = static_cast(u_calloc(1, sizeof(attachment_send_req))); QVERIFY(request); auto discard = qScopeGuard([&] { attachment_send_discard(request); }); request->compressor = audio_compressor_create(); QVERIFY(request->compressor); const audio_compressor_config_t settings{}; QCOMPARE(audio_compressor_configure(request->compressor, &settings), 0); const std::vector samples(24000, 0); QCOMPARE(audio_compressor_push(request->compressor, samples.data(), samples.size()), 0); attachment_send_discard(request); request = nullptr; QCOMPARE(u_get_allocated_count(), baseline); } void orderedWindowShutdownPersistsGeometry() { QTemporaryDir directory; QVERIFY(directory.isValid()); const QString path = directory.filePath("shutdown.cfg"), log = directory.filePath("shutdown.log"); QFile config(path); QVERIFY(config.open(QIODevice::WriteOnly)); const QByteArray text = "[global]\nauto_sockets=no\n" "my_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n" "member_files_enabled=0\nlocal_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n"; QCOMPARE(config.write(text), text.size()); config.close(); auto logger = qScopeGuard([] { debug_disable_file_output(); }); DbManager db; MainWindow window(nullptr, &db, path, directory.path(), log); auto* node = window.utunNode(); if (!node || !node->isRunning()) { directory.setAutoRemove(false); DEBUG_ERROR(DEBUG_CATEGORY_SYS, "Shutdown fixture startup failed; diagnostics: %s", qPrintable(log)); } QVERIFY(node && node->isRunning()); QTRY_VERIFY(db.isOpen()); window.resize(1017, 713); const QRect geometry = window.normalGeometry(); QVERIFY(!geometry.isEmpty()); const QString expected = QStringLiteral("%1,%2,%3,%4").arg(geometry.width()).arg(geometry.height()).arg(geometry.x()).arg(geometry.y()); QSignalSpy stopping(node, &UtunNode::stopping), stopped(node, &UtunNode::stopped); window.requestShutdown(); QTRY_COMPARE_WITH_TIMEOUT(stopped.size(), 1, 5000); QCOMPARE(stopping.size(), 1); QVERIFY(!db.isOpen()); sqlite3* saved = nullptr; QCOMPARE(sqlite3_open_v2(qPrintable(directory.filePath("chats.db")), &saved, SQLITE_OPEN_READONLY, nullptr), SQLITE_OK); auto close = qScopeGuard([&] { sqlite3_close(saved); }); DbManager snapshot; snapshot.setDb(saved); QCOMPARE(snapshot.getUiState("window_geometry"), expected); QCOMPARE(snapshot.getUiState("window_maximized"), QString("0")); QVERIFY(!snapshot.getUiState("window_splitter").isEmpty()); snapshot.setDb(nullptr); } void dmAcknowledgment_data() { QTest::addColumn("scenario"); for (const auto& name : {"executed", "stale-generation", "cancelled", "unavailable"}) QTest::newRow(name) << QString(name); #ifdef UTUN_TEST_DESKTOP_HOOKS QTest::newRow("queue-oom") << QString("queue-oom"); #endif } void dmAcknowledgment() { QFETCH(QString, scenario); DesktopCore core; QVERIFY(core.start()); SC_MYKEYS peer{}; uint8_t ed[32]; QCOMPARE(sc_generate_keypair(&peer), SC_OK); QCOMPARE(sc_derive_ed25519_pubkey(peer.private_key, ed), SC_OK); QCOMPARE(dm_start(core.inst, sc_derive_node_id_from_pubkey(peer.public_key), peer.public_key, ed, "Peer", nullptr), 0); DbManager db; db.setDb(chat_core_get_db(core.inst)); const auto conversations = db.getDmConversations(); QCOMPARE(conversations.size(), 1); core.poll(); #ifdef UTUN_TEST_DESKTOP_HOOKS watching = true; #endif auto* request = static_cast(u_calloc(1, sizeof(dm_send_req) + 5)); QVERIFY(request); qstrncpy(request->conv_id, qPrintable(conversations.first().convId), sizeof(request->conv_id)); request->generation = conversations.first().generation + (scenario == "stale-generation"); request->request_id = 88; request->data_len = 5; request->data = reinterpret_cast(request + 1); memcpy(request->data, "hello", 5); if (scenario == "unavailable") gui_bridge_set_inst(nullptr); #ifdef UTUN_TEST_DESKTOP_HOOKS failAllocation = scenario == "queue-oom" ? 1 : 0; #endif const bool accepted = scenario != "unavailable" && scenario != "queue-oom"; const int rc = gui_bridge_submit_dm_message(request); QCOMPARE(rc, accepted ? 0 : -1); if (rc) u_free(request); if (scenario == "cancelled") gui_bridge_set_inst(nullptr); else if (accepted) core.poll(); if (accepted) { QTRY_VERIFY(submissions.contains(88)); QCOMPARE(submissions.value(88), scenario == "executed"); } else { QCoreApplication::processEvents(); QVERIFY(!submissions.contains(88)); } QCOMPARE(db.getDmConversations().first().lastOutSeq, qint64(scenario == "executed" ? 1 : 0)); } void dmComposerKeepsRejectedDraft() { InputBar input(nullptr, nullptr); input.setDmMode(true); input.setRecordingContext(".", "dm:7", 1); auto* editor = input.findChild(); QVERIFY(editor); editor->setPlainText("keep this draft"); quint64 request = 0; connect(&input, &InputBar::sendMessage, &input, [&](const QString&, const QByteArray&, quint64 id) { request = id; input.submissionResult(id, false); }); QVERIFY(QMetaObject::invokeMethod(&input, "onSendClicked", Qt::DirectConnection)); QVERIFY(request); QCOMPARE(editor->toPlainText(), QString("keep this draft")); input.submissionResult(0, false); QCOMPARE(editor->toPlainText(), QString("keep this draft")); } void sqliteReadsAreAtomicSnapshots() { sqlite3* handle = nullptr; QCOMPARE(sqlite3_open(":memory:", &handle), SQLITE_OK); auto close = qScopeGuard([&] { sqlite3_close(handle); }); QCOMPARE(sqlite3_exec(handle, "CREATE TABLE accounts(node_id,display_name,avatar_color,avatar_letter,is_contact);" "INSERT INTO accounts VALUES(1,'Alice','red','A',1),(2,'Bob','blue','B',1);" "CREATE TABLE ui_state(key,value);INSERT INTO ui_state VALUES('draft','original');" "CREATE TABLE attachment_keys(channel,message_id,ready,downloaded,decrypting,error,plain_path,info,content_type,key);" "CREATE TABLE attachment_key_requests(channel,message_id,incoming,state);" "INSERT INTO attachment_keys VALUES('42',zeroblob(32),1,1,0,0,'original.opus',X'010203','audio/opus',zeroblob(32));", nullptr, nullptr, nullptr), SQLITE_OK); DbManager db; db.setDb(handle); QCOMPARE(db.getAccounts().size(), 2); QCOMPARE(db.getUiState("draft"), QString("original")); QVERIFY(!db.prepareOrNull("DELETE FROM accounts")); QCOMPARE(db.getAccounts().size(), 2); const auto snapshot = db.getAttachment("42", QByteArray(32, '\0')); QVERIFY(snapshot.found && snapshot.ready && snapshot.downloaded && snapshot.hasKey); QCOMPARE(snapshot.info, QByteArray::fromHex("010203")); QCOMPARE(snapshot.requestState, -1); QCOMPARE(sqlite3_exec(handle, "UPDATE attachment_keys SET plain_path='new.opus',info=X'04'", nullptr, nullptr, nullptr), SQLITE_OK); QCOMPARE(snapshot.path, QString("original.opus")); QCOMPARE(snapshot.info, QByteArray::fromHex("010203")); QCOMPARE(db.getAttachment("42", QByteArray(32, '\0')).path, QString("new.opus")); #ifdef UTUN_TEST_DESKTOP_HOOKS sqlMatch = "FROM accounts"; sqlStage = 1; sqlCalls = 2; QVERIFY(db.getAccounts().isEmpty()); QCOMPARE(sqlStage, 0); sqlMatch = "FROM ui_state"; sqlStage = 2; sqlCalls = 1; QVERIFY(db.getUiState("draft").isEmpty()); QCOMPARE(sqlStage, 0); sqlMatch = "FROM accounts"; sqlStage = 3; sqlCalls = 1; QVERIFY(db.getAccounts().isEmpty()); QCOMPARE(sqlStage, 0); sqlMatch = "FROM attachment_keys"; sqlStage = 2; sqlCalls = 1; QVERIFY(!db.getAttachment("42", QByteArray(32, '\0')).found); QCOMPARE(sqlStage, 0); #endif QCOMPARE(db.getAccounts().size(), 2); QCOMPARE(db.getUiState("draft"), QString("original")); QVERIFY(!sqlite3_next_stmt(handle, nullptr)); db.setDb(nullptr); } void mediaGesture_data() { QTest::addColumn("gesture"); for (const auto& name : {"release-only", "drag", "blank", "click"}) QTest::newRow(name) << QString(name); } void mediaGesture() { QFETCH(QString, gesture); QListView view; MessageDelegate delegate; QStandardItemModel model; auto* item = new QStandardItem; item->setData("video/mp4", MsgContentTypeRole); item->setData("42", MsgChannelIdRole); item->setData(1, MsgIdRole); model.appendRow(item); QStyleOptionViewItem option; option.widget = &view; option.rect = QRect(0, 0, 500, 200); int downloads = 0; delegate.setDownloadCallback([&](const QModelIndex&) { ++downloads; return true; }); const QPointF begin = gesture == "blank" ? QPointF(450, 40) : QPointF(100, 40); const QPointF end = gesture == "drag" ? QPointF(220, 40) : begin; QMouseEvent press(QEvent::MouseButtonPress, begin, begin, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QMouseEvent release(QEvent::MouseButtonRelease, end, end, Qt::LeftButton, Qt::NoButton, Qt::NoModifier); if (gesture != "release-only") delegate.editorEvent(&press, &model, option, model.index(0, 0)); delegate.editorEvent(&release, &model, option, model.index(0, 0)); QCOMPARE(downloads, gesture == "click" ? 1 : 0); } void voiceTimerSurvivesViewDestructionAndStopsWithoutPaint() { auto* sound = SoundManager::instance(); QVERIFY(sound->init()); auto shutdown = qScopeGuard([&] { MessageDelegate::stopAllAutoPlay(); sound->shutdown(); }); auto pcm = std::make_shared>(48000, 0); const auto id = sound->playRawPcm(pcm->data(), int(pcm->size()), 48000, 1, pcm); QVERIFY(id); QStandardItemModel model; auto* item = new QStandardItem; item->setData("audio/opus", MsgContentTypeRole); item->setData("voice-test.opus", MsgVoiceFileRole); item->setData("1.0s", MsgVoiceDurationRole); item->setData(2, MsgFileDownloadStateRole); model.appendRow(item); MessageDelegate delegate; auto* view = new QListView; QStyleOptionViewItem option; option.widget = view; option.rect = QRect(0, 0, 500, 200); QImage image(500, 200, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent); { QPainter painter(&image); delegate.paint(&painter, option, model.index(0, 0)); } MessageDelegate::startAutoPlay("voice-test.opus", 1.0f, id); auto* timer = qApp->findChild("messageVoiceTimer"); QVERIFY(timer && timer->isActive()); QTRY_VERIFY_WITH_TIMEOUT(sound->pcmCursor(id) > 0, 2000); // Ни paint, ни видимость view не должны требоваться для завершения прогресса. sound->stopPcm(id); QTRY_VERIFY_WITH_TIMEOUT(!timer->isActive(), 3000); MessageDelegate::startAutoPlay("voice-test.opus", 1.0f, 999); QVERIFY(timer->isActive()); delete view; QVERIFY(!timer->isActive()); QTest::qWait(30); } void lifecycle_data() { QTest::addColumn("action"); QTest::addColumn("scenario"); QTest::addColumn("window"); QStringList scenarios{"unavailable", "stopped", "cancelled", "executed"}; #ifdef UTUN_TEST_DESKTOP_HOOKS scenarios << "request-oom" << "queue-oom"; #endif for (int action = GUI_RADIO_CALL_START; action <= GUI_RADIO_CALL_CONTINUE; ++action) for (const auto& scenario : scenarios) QTest::newRow(qPrintable(QString("bridge-%1-%2").arg(action).arg(scenario))) << action << scenario << false; for (const auto& scenario : scenarios) QTest::newRow(qPrintable("window-" + scenario)) << int(GUI_RADIO_CALL_START) << scenario << true; QTest::newRow("window-destroyed-before-execution") << int(GUI_RADIO_CALL_START) << QString("destroyed") << true; } void lifecycle() { QFETCH(int, action); QFETCH(QString, scenario); QFETCH(bool, window); DesktopCore core; QVERIFY(core.start()); std::unique_ptr form; if (window) { form = std::make_unique(nullptr, nullptr, QString()); // UtunNode остаётся владельцу fixture. core.poll(); QCoreApplication::processEvents(); // Отделить стартовые настройки media от проверяемой команды. } if (scenario == "unavailable") gui_bridge_set_inst(nullptr); if (scenario == "stopped") uasync_mark_stopped(core.ua); #ifdef UTUN_TEST_DESKTOP_HOOKS watching = true; failAllocation = scenario == "request-oom" ? 1 : scenario == "queue-oom" ? 2 : 0; #endif const bool accepted = scenario == "executed" || scenario == "cancelled" || scenario == "destroyed"; const uint64_t group = action == GUI_RADIO_CALL_START ? 7654321 : 0, peer = action == GUI_RADIO_CALL_START ? 42 : 0; if (form) form->onRadioCallRequested(QString::number(group), peer); else QCOMPARE(gui_bridge_radio_call(static_cast(action), 73, group, peer), accepted ? 0 : -1); QCOMPARE(pending(core.ua), accepted ? 1u : 0u); if (scenario == "destroyed") form.reset(); const bool executes = scenario == "executed" || scenario == "destroyed"; if (executes) core.poll(); else gui_bridge_set_inst(nullptr); QCOMPARE(pending(core.ua), 0u); QVERIFY(bindingValid); QCOMPARE(core.results, executes && action == GUI_RADIO_CALL_START ? 1u : 0u); QVERIFY(core.resultValid); #ifdef UTUN_TEST_DESKTOP_HOOKS QCOMPARE(failAllocation.load(), 0); QCOMPARE(observed.size(), executes ? 1 : 0); if (executes) { static const int nativeActions[]{RADIO_CALL_START, RADIO_CALL_READY, RADIO_CALL_ACCEPT, RADIO_CALL_DECLINE, RADIO_CALL_CANCEL, RADIO_CALL_FAIL, RADIO_CALL_REJECT_BUSY, RADIO_CALL_CONTINUE}; const auto& request = observed.front(); QCOMPARE(request.inst, core.inst); QCOMPARE(request.action, nativeActions[action]); QCOMPARE(request.group, group); QCOMPARE(request.peer, peer); if (window) QVERIFY(request.id); else QCOMPARE(request.id, uint64_t(73)); } #endif DEBUG_INFO(DEBUG_CATEGORY_RADIO, "Radio command verified: window=%d action=%d scenario=%s results=%u", window, action, qPrintable(scenario), core.results); } void invalidArguments() { DesktopCore core; QVERIFY(core.start()); const auto baseline = u_get_allocated_count(); QCOMPARE(gui_bridge_radio_call(static_cast(99), 1, 0, 0), -1); QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 0, 1, 1), -1); QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 1, 0, 1), -1); QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 1, 1, 0), -1); QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 0, 0, 0), -1); QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 1, 1, 0), -1); QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 1, 0, 1), -1); QCOMPARE(pending(core.ua), 0u); QCOMPARE(u_get_allocated_count(), baseline); } void concurrentRebind() { DesktopCore first, second; QVERIFY(first.start()); gui_bridge_set_inst(nullptr); QVERIFY(second.start()); #ifdef UTUN_TEST_DESKTOP_HOOKS watching = true; #endif std::atomic turn{0}, submitted{0}; std::thread producer([&] { for (unsigned id = 1; id <= 96; ++id) { while (turn < id) std::this_thread::yield(); gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, id, 0, 0); submitted = id; } }); bool controlsAccepted = true; for (unsigned i = 1; i <= 96; ++i) { turn = i; gui_bridge_set_inst(nullptr); auto& core = i % 2 ? first : second; core.attach(); while (submitted < i) std::this_thread::yield(); first.poll(); second.poll(); // Контроль исполняется у каждого владельца даже при отклонении/отмене конкурирующей команды. controlsAccepted &= gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 1000 + i, 0, 0) == 0; core.poll(); } producer.join(); gui_bridge_set_inst(nullptr); first.attach(); QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 2000, 7654321, 42), 0); first.poll(); QVERIFY(controlsAccepted); QVERIFY(bindingValid); QCOMPARE(pending(first.ua), 0u); QCOMPARE(pending(second.ua), 0u); QCOMPARE(first.results, 1u); QCOMPARE(second.results, 0u); QVERIFY(first.resultValid); #ifdef UTUN_TEST_DESKTOP_HOOKS size_t firstCount = 0, secondCount = 0; for (const auto& request : observed) { firstCount += request.inst == first.inst; secondCount += request.inst == second.inst; } QVERIFY(firstCount >= 49); QVERIFY(secondCount >= 48); QCOMPARE(observed.back().inst, first.inst); QCOMPARE(observed.back().id, uint64_t(2000)); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "Radio binding verified: first_executed=%zu second_executed=%zu binding_valid=%d", firstCount, secondCount, bindingValid); #endif } }; QTEST_MAIN(DesktopCommandsTest) #include "test_desktop_commands.moc"