/* Настоящие MainWindow/uasync/SQLite: контекстное меню, composer и обновление существующей строки/цитаты. */ #include "../src/mainwindow.h" #include "../src/messagelist.h" #include "../src/messagedelegate.h" #include "../src/chatview.h" #include "../src/inputbar.h" #include "../src/soundsettingspage.h" #include "../src/channelsettingsdialog.h" #include "../src/animtimer.h" #include "../db/db_manager.h" #include "../transport/utun_node.h" #include "../transport/gui_bridge.h" #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 "debug_config.h" #include "mem.h" } static QPointer sendHistory; #ifdef Q_OS_LINUX static std::atomic sendAllocationFailure{0}; // 1=text request, 2=запись очереди моста. extern "C" void* __real_u_malloc_impl(uint32_t, const char*); extern "C" void* __real_u_calloc_impl(uint32_t, uint32_t, const char*); extern "C" void* __wrap_u_malloc_impl(uint32_t bytes, const char* where) { int expected = 1; if (strstr(where, "/messagelist.cpp:") && sendAllocationFailure.compare_exchange_strong(expected, 0)) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "[INJECT] text request allocation failure"); return nullptr; } return __real_u_malloc_impl(bytes, where); } extern "C" void* __wrap_u_calloc_impl(uint32_t count, uint32_t bytes, const char* where) { int expected = 2; if (strstr(where, "/gui_bridge_impl.cpp:") && sendAllocationFailure.compare_exchange_strong(expected, 0)) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "[INJECT] submission queue allocation failure"); return nullptr; } return __real_u_calloc_impl(count, bytes, where); } #endif /* Детерминированный owner uasync: ядро выполняется только при pump, реальные виджеты получают события через мост. */ struct SendCore { UASYNC* ua = uasync_create(); UTUN_INSTANCE* instance = nullptr; DbManager db; QPointer history; QString channel; QByteArray original; ~SendCore() { #ifdef Q_OS_LINUX sendAllocationFailure = 0; #endif delete history.data(); sendHistory.clear(); gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr); gui_bridge_set_msg_submitted_cb(nullptr); db.setDb(nullptr); if (ua) uasync_mark_stopped(ua); if (instance) utun_instance_destroy(instance); if (ua) uasync_destroy(ua, 0); QCoreApplication::processEvents(); AnimTimer::shutdown(); MessageList::cleanupAnimations(); } void attach() { uasync_mark_running(ua); gui_bridge_set_uasync(ua); gui_bridge_set_inst(instance); } void pump() { uasync_poll(ua, 0); QCoreApplication::processEvents(); } InputBar* input() const { return history->findChild(); } QTextEdit* editor() const { return input()->findChild(); } bool send() { return QMetaObject::invokeMethod(input(), "onSendClicked", Qt::DirectConnection); } void openHistory() { history = new MessageList(&db); sendHistory = history; history->loadChannel(channel); } bool start(bool reply = false) { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_CHAT, DEBUG_LEVEL_DEBUG); debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); gui_bridge_init(); instance = ua ? utun_instance_create_from_str(ua, "[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" "tun_enabled=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n" "local_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n") : nullptr; if (!instance || utun_core_start(instance) || chat_service_start(instance)) return false; const auto id = chat_core_create_channel_auto(instance, "Send tests"); if (!id) return false; channel = QString::number(id); db.setDb(instance->topo_sqlite_db); db.setMyNodeId(instance->node_id); if (reply) { chat_msg_submit request{}; request.inst = instance; strcpy(request.content_type, "text/plain"); snprintf(request.channel_id, sizeof(request.channel_id), "%s", qPrintable(channel)); request.data = (uint8_t*)"Original"; request.data_len = 8; if (chat_core_submit_message(instance, &request)) return false; original = QByteArray((const char*)request.message_id, 32); } chat_event_set_handler(instance, [](UTUN_INSTANCE*, int type, const uint8_t* data, int len) { gui_bridge_post(type, data, len); }); gui_bridge_set_msg_submitted_cb([](const uint8_t* data, int len) { if (sendHistory) sendHistory->onMessageSubmitted(data, len); }); attach(); openHistory(); pump(); if (reply) input()->setReply(original, "Member", "Original"); return true; } }; static int newMessages; static int inCore(std::function operation) { auto* task = new std::packaged_task([operation] { return operation(gui_bridge_get_inst()); }); auto result = task->get_future(); gui_bridge_post_uasync_fn([](void* arg) { std::unique_ptr> work(static_cast*>(arg)); (*work)(); }, task); if (result.wait_for(std::chrono::seconds(5)) != std::future_status::ready) qFatal("Edit test: core task timed out"); return result.get(); } class GroupEditTest : public QObject { Q_OBJECT private slots: void channelSend_data() { QTest::addColumn("reply"); QTest::addColumn("failure"); QStringList failures{"none", "unavailable", "stopped", "cancelled", "sql"}; #ifdef Q_OS_LINUX failures << "text-allocation" << "queue-allocation"; #endif for (bool reply : {false, true}) for (const auto& failure : failures) QTest::newRow(qPrintable(QString("%1-%2").arg(reply ? "reply" : "ordinary", failure))) << reply << failure; } void channelSend() { QFETCH(bool, reply); QFETCH(QString, failure); const auto baseline = u_get_allocated_count(); { SendCore core; QVERIFY(core.start(reply)); QSignalSpy errors(core.input(), &InputBar::attachmentRejected); QSignalSpy sends(core.input(), &InputBar::sendMessage); const auto before = u_get_allocated_count(); if (failure == "unavailable") gui_bridge_set_inst(nullptr); if (failure == "stopped") uasync_mark_stopped(core.ua); QByteArray table = core.db.msgTableName(core.channel).toUtf8(); const auto clearAuthorizer = qScopeGuard([&] { sqlite3_set_authorizer(core.db.m_db, nullptr, nullptr); }); if (failure == "sql") QCOMPARE(sqlite3_set_authorizer(core.db.m_db, [](void* arg, int action, const char* table, const char*, const char*, const char*) { if (action == SQLITE_INSERT && table && !strcmp(table, static_cast(arg))) { DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "[INJECT] message INSERT refused"); return SQLITE_DENY; } return SQLITE_OK; }, table.data()), SQLITE_OK); #ifdef Q_OS_LINUX if (failure == "text-allocation") sendAllocationFailure = 1; if (failure == "queue-allocation") sendAllocationFailure = 2; #endif const QString text = QString::fromUtf8("Канальный текст 👍"); core.editor()->setPlainText(text); QVERIFY(core.send()); QCOMPARE(sends.size(), 1); QVERIFY(sends[0][2].toULongLong()); QCOMPARE(core.editor()->toPlainText(), text); // До pump сохранение ещё не подтверждено. if (failure == "cancelled") { gui_bridge_set_inst(nullptr); QCOMPARE(u_get_allocated_count(), before); QCoreApplication::processEvents(); } else if (failure != "unavailable" && failure != "stopped") core.pump(); const bool success = failure == "none"; QCOMPARE(errors.size(), success ? 0 : 1); QCOMPARE(core.editor()->toPlainText(), success ? QString() : text); QCOMPARE(core.db.getMessages(core.channel, 200).size(), int(reply) + int(success)); QCOMPARE(sqlite3_set_authorizer(core.db.m_db, nullptr, nullptr), SQLITE_OK); if (!success) { #ifdef Q_OS_LINUX QCOMPARE(sendAllocationFailure.load(), 0); #endif core.attach(); QVERIFY(core.send()); QCOMPARE(sends.size(), 2); QVERIFY(sends[0][2] != sends[1][2]); core.pump(); QVERIFY(core.editor()->toPlainText().isEmpty()); QCOMPARE(core.db.getMessages(core.channel, 200).size(), int(reply) + 1); } } QCOMPARE(u_get_allocated_count(), baseline); } void lateSendResultsKeepNewDraft() { SendCore core; QVERIFY(core.start()); QSignalSpy sends(core.input(), &InputBar::sendMessage); core.editor()->setPlainText("first"); QVERIFY(core.send()); QVERIFY(core.send()); QCOMPARE(sends.size(), 1); core.editor()->setPlainText("next draft"); core.pump(); QCOMPARE(core.editor()->toPlainText(), QString("next draft")); QCOMPARE(core.db.getMessages(core.channel, 200).size(), 1); } void lateResultCannotAcknowledgeNewComposer() { SendCore core; QVERIFY(core.start()); core.editor()->setPlainText("old composer"); QVERIFY(core.send()); uasync_poll(core.ua, 0); // Старый success уже в Qt-очереди, новый запрос ещё не выполнялся. delete core.history.data(); core.openHistory(); core.editor()->setPlainText("new composer"); QVERIFY(core.send()); QCoreApplication::processEvents(); QCOMPARE(core.editor()->toPlainText(), QString("new composer")); core.pump(); QVERIFY(core.editor()->toPlainText().isEmpty()); QCOMPARE(core.db.getMessages(core.channel, 200).size(), 2); } void channelSettingsCanLoseMainWindow() { debug_config_init(); gui_bridge_init(); QTemporaryDir directory; QVERIFY(directory.isValid()); directory.setAutoRemove(false); auto diagnostics = qScopeGuard([&] { directory.setAutoRemove(!QTest::currentTestFailed()); }); const QString path = directory.filePath("node.cfg"); QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); const QByteArray config = "[global]\n" "my_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" "tun_enabled=no\n[server:local]\naddr=127.0.0.1:0\ntype=private\n" "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nlocal_invites_enabled=0\nlocal_broadcasts_enabled=0\n" "[ntp]\nenabled=no\n"; QCOMPARE(file.write(config), config.size()); file.close(); DbManager db; QPointer window = new MainWindow(nullptr, &db, path, directory.filePath("db"), directory.filePath("dialog.log"), "warn"); auto release = qScopeGuard([&] { delete window.data(); }); QVERIFY2(window->utunNode()->isRunning(), qPrintable(window->utunNode()->startupError())); QTRY_VERIFY_WITH_TIMEOUT(db.isOpen(), 3000); QPointer settings; bool observed = false; QTimer::singleShot(0, [&] { settings = qobject_cast(QApplication::activeModalWidget()); observed = bool(settings); delete window.data(); }); QVERIFY(QMetaObject::invokeMethod(window.data(), "showChannelSettings", Qt::DirectConnection, Q_ARG(QString, QString("1")))); QVERIFY(observed); QVERIFY(!window); QVERIFY(!settings); } void editThroughMenuAndCore() { debug_config_init(); gui_bridge_init(); QTemporaryDir directory; QVERIFY(directory.isValid()); directory.setAutoRemove(false); auto diagnostics = qScopeGuard([&] { directory.setAutoRemove(!QTest::currentTestFailed()); }); const auto configPath = directory.filePath("node.cfg"); QFile config(configPath); QVERIFY(config.open(QIODevice::WriteOnly)); const QByteArray settings = "[global]\n" "my_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" "tun_enabled=no\n[server:local]\naddr=127.0.0.1:0\ntype=private\n" "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nlocal_invites_enabled=0\nlocal_broadcasts_enabled=0\n" "[ntp]\nenabled=no\n"; QCOMPARE(config.write(settings), settings.size()); config.close(); DbManager db; MainWindow window(nullptr, &db, configPath, directory.filePath("db"), directory.filePath("edit.log"), "warn", "chat_sync=debug"); window.show(); QFile log(directory.filePath("edit.log")); const QByteArray startupLog = log.open(QIODevice::ReadOnly) ? log.readAll() : QByteArray("log unavailable"); QVERIFY2(window.utunNode()->isRunning(), qPrintable(window.utunNode()->startupError() + "\n" + startupLog)); QTRY_VERIFY_WITH_TIMEOUT(db.isOpen(), 3000); QCOMPARE(inCore([](UTUN_INSTANCE* inst) { chat_core_create_channel_auto(inst, "Editable"); return 0; }), 0); QTRY_COMPARE(db.getChannels().size(), 1); const auto channel = db.getChannels().first().channelId; QByteArray id, replyId; QCOMPARE(inCore([&](UTUN_INSTANCE* inst) { chat_msg_submit req{}; req.inst = inst; strcpy(req.content_type, "text/plain"); snprintf(req.channel_id, sizeof(req.channel_id), "%s", qPrintable(channel)); req.data = (uint8_t*)"Original"; req.data_len = 8; if (chat_core_submit_message(inst, &req)) return -1; id = QByteArray((const char*)req.message_id, 32); memcpy(req.reply_to, req.message_id, 32); req.data = (uint8_t*)"Reply"; req.data_len = 5; const int rc = chat_core_submit_message(inst, &req); replyId = QByteArray((const char*)req.message_id, 32); return rc; }), 0); auto* history = window.findChild(); QVERIFY(history); history->loadChannel(channel); auto* view = history->findChild(); auto* input = history->findChild(); QVERIFY(view && input); auto* editor = input->findChild(); QVERIFY(editor); QTRY_COMPARE(view->model()->rowCount(), 2); auto indexFor = [&](const QByteArray& key) { for (int row = 0; row < view->model()->rowCount(); row++) { const auto index = view->model()->index(row, 0); if (index.data(MsgMessageKeyRole).toString() == QString::fromLatin1(key.toHex())) return index; } return QModelIndex(); }; QCOMPARE(indexFor(id).data(MsgTextRole).toString(), QString("Original")); editor->setPlainText("Unsent draft"); auto openEditor = [&] { QCoreApplication::processEvents(); const auto original = indexFor(id); QVERIFY(original.isValid()); view->scrollTo(original); const auto pos = view->visualRect(original).center(); QCOMPARE(view->indexAt(pos), original); QTimer::singleShot(0, [] { auto* menu = qobject_cast(QApplication::activePopupWidget()); if (!menu) qFatal("Edit menu missing"); for (auto* action : menu->actions()) if (action->text() == "Edit message") { action->trigger(); menu->close(); return; } qFatal("Edit action missing"); }); QMetaObject::invokeMethod(view, "customContextMenuRequested", Qt::DirectConnection, Q_ARG(QPoint, pos)); }; openEditor(); QCOMPARE(editor->toPlainText(), QString("Original")); gui_bridge_set_msg_received_cb([](const char*, int, uint64_t, int) { ++newMessages; }); newMessages = 0; editor->setPlainText(QString::fromUtf8("Изменено 👍")); QTest::keyClick(editor, Qt::Key_Return); QTRY_COMPARE_WITH_TIMEOUT(indexFor(id).data(MsgVersionRole).toULongLong(), quint64(2), 3000); QCOMPARE(view->model()->rowCount(), 2); QCOMPARE(indexFor(id).data(MsgTextRole).toString(), QString::fromUtf8("Изменено 👍")); QVERIFY(indexFor(id).data(MsgTimeRole).toString().contains("edited ")); QCOMPARE(indexFor(replyId).data(MsgQuoteTextRole).toString(), QString::fromUtf8("Изменено 👍")); QTRY_COMPARE(editor->toPlainText(), QString("Unsent draft")); QCOMPARE(newMessages, 0); openEditor(); editor->setPlainText("Stale editor draft"); QCOMPARE(inCore([&](UTUN_INSTANCE* inst) { return chat_core_edit_message(inst, qPrintable(channel), (const uint8_t*)id.constData(), 2, "Remote edit", 11); }), 0); QTRY_COMPARE(indexFor(id).data(MsgVersionRole).toULongLong(), quint64(3)); QCOMPARE(editor->toPlainText(), QString("Stale editor draft")); QSignalSpy failures(input, &InputBar::attachmentRejected); QTest::keyClick(editor, Qt::Key_Return); QTRY_COMPARE(failures.size(), 1); QCOMPARE(editor->toPlainText(), QString("Stale editor draft")); QCOMPARE(indexFor(id).data(MsgTextRole).toString(), QString("Remote edit")); QCOMPARE(newMessages, 0); QTest::keyClick(editor, Qt::Key_Escape); QCOMPARE(editor->toPlainText(), QString("Unsent draft")); SoundSettingsPage sounds(&db); sounds.loadFromDb(); const auto checks = sounds.findChildren(); const auto sliders = sounds.findChildren(); QCOMPARE(checks.size(), 9); QCOMPARE(sliders.size(), 9); for (int i = 0; i < 9; i++) { checks[i]->setChecked(i % 2 == 0); sliders[i]->setValue(20 + i); } checks[0]->setChecked(false); sounds.applyAndSave(); QCOMPARE(inCore([](UTUN_INSTANCE*) { return 0; }), 0); QCOMPARE(db.getUiStateInt("sound_enabled", -1), 0); QCOMPARE(db.getUiStateInt("sound_volume", -1), 20); const char* events[] = {"msg_incoming", "msg_outgoing", "connect", "disconnect", "call_incoming", "call_connecting", "call_ringing", "radio_incoming"}; for (int i = 0; i < 8; i++) { QCOMPARE(db.getUiStateInt(QString("sound_%1_on").arg(events[i]), -1), (i + 1) % 2 == 0 ? 1 : 0); QCOMPARE(db.getUiStateInt(QString("sound_%1_vol").arg(events[i]), -1), 21 + i); } gui_bridge_set_msg_received_cb(nullptr); } }; QTEST_MAIN(GroupEditTest) #include "test_group_edits.moc"