/* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */ #include "../src/accountlist.h" #include "../src/memberlistmodel.h" #include "../transport/gui_bridge.h" #include "../db/db_manager.h" #include #include #include #include #include #include #include #include extern "C" { #include "utun_instance.h" #include "chat/chat_core.h" #include "chat/chat_member.h" #include "member_files/protocol.h" #include "debug_config.h" #include "mem.h" } static UTUN_INSTANCE instance{}; static std::vector requests; static gui_member_detail_fn detailCallback; static bool ready = true; static bool rejectOwned = false; static int ownedPosts = 0; static std::vector memberRequests; static std::vector connectRequests; extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; } extern "C" int gui_bridge_is_uasync_ready() { return ready; } extern "C" void gui_bridge_set_member_detail_cb(gui_member_detail_fn cb) { detailCallback = cb; } extern "C" void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn) {} extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t) { return 0; } extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) { if (fn == chat_core_collect_member_detail_trampoline) requests.push_back(*static_cast(arg)); if (fn == chat_core_request_member_list_trampoline || fn == chat_core_request_member_rtt_trampoline) memberRequests.push_back(*static_cast(arg)); if (fn == chat_core_connect_node_trampoline) connectRequests.push_back(*static_cast(arg)); u_free(arg); } extern "C" int gui_bridge_post_uasync_owned_fn(void (*fn)(void*), void* arg) { ++ownedPosts; if (rejectOwned) return -1; gui_bridge_post_uasync_fn(fn, arg); return 0; } static QByteArray row(quint64 node, bool online = true, int up = 5, int rtt = 30) { chat_member_display member{}; member.node_id = node; member.online = online; member.conn_presence = 5; member.conn_up = up; member.rtt = rtt; member.nat_type = 0xff; member.flags = CHAT_MEMBER_FLAG_PHONE_KNOWN; snprintf(member.name, sizeof(member.name), "peer%llu", (unsigned long long)node); QByteArray bytes(CHAT_MEMBER_DISPLAY_SIZE, 0); chat_core_serialize_member(&member, reinterpret_cast(bytes.data())); return bytes; } static QByteArray reply(const chat_member_detail_req& req, const QList& hops = {}, int result = 0) { QByteArray bytes(CHAT_MEMBER_DETAIL_HEADER_SIZE + hops.size() * 8, 0); auto* data = reinterpret_cast(bytes.data()); mf_put64(data, req.request_id); mf_put64(data + 8, req.group_id); mf_put64(data + 16, req.node_id); data[24] = result; data[25] = result ? 0 : 7; data[28] = hops.size(); for (int i = 0; i < hops.size(); i++) mf_put64(data + CHAT_MEMBER_DETAIL_HEADER_SIZE + i * 8, hops[i]); return bytes; } static void deliver(const QByteArray& bytes) { if (detailCallback) detailCallback(reinterpret_cast(bytes.constData()), bytes.size()); } static void fill(AccountList& panel, const QString& channel, const QByteArray& bytes) { panel.setChannel(channel); panel.onMemberList(channel, bytes.size() / CHAT_MEMBER_DISPLAY_SIZE, reinterpret_cast(bytes.constData()), bytes.size()); } class MemberDetailTest : public QObject { Q_OBJECT static QString body(AccountList& p) { return p.findChild("memberDetailText")->toPlainText(); } static QString header(AccountList& p) { return p.findChild("memberDetailHeader")->text(); } static bool select(AccountList& p, int rowNumber) { return QMetaObject::invokeMethod(&p, "onMemberClicked", Qt::DirectConnection, Q_ARG(QModelIndex, p.model()->index(rowNumber))); } static bool context(AccountList& panel, const QString& actionText, const std::function& change = {}) { const QPoint point = panel.findChild()->visualRect(panel.model()->index(0)).center(); bool clicked = false; QTimer choose, deadline; choose.setSingleShot(true); deadline.setSingleShot(true); QObject::connect(&deadline, &QTimer::timeout, &deadline, [] { if (auto* menu = qobject_cast(QApplication::activePopupWidget())) menu->close(); }); QObject::connect(&choose, &QTimer::timeout, &choose, [&] { auto* menu = qobject_cast(QApplication::activePopupWidget()); if (!menu) { QTest::qFail("member context menu was not shown", __FILE__, __LINE__); return; } if (change) change(); if (!menu->isVisible()) return; for (auto* action : menu->actions()) if (action->text() == actionText) { if (!action->isEnabled()) { menu->close(); return; } clicked = true; QTest::mouseClick(menu, Qt::LeftButton, Qt::NoModifier, menu->actionGeometry(action).center()); return; } menu->close(); QTest::qFail("member context action was not found", __FILE__, __LINE__); }); choose.start(0); deadline.start(1000); const bool invoked = QMetaObject::invokeMethod(&panel, "onMemberContextMenu", Qt::DirectConnection, Q_ARG(QPoint, point)); return invoked && clicked; } private slots: void init() { requests.clear(); memberRequests.clear(); connectRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true; debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); } void flatModelPreservesRowsSnapshotsAndPersistentIndexes() { MemberListModel model; QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest); model.setChannel("123", false); auto bytes = row(7) + row(8) + row(9); model.onMemberList("123", 3, reinterpret_cast(bytes.constData()), bytes.size()); QCOMPARE(model.rowCount(model.index(0)), 0); const QPersistentModelIndex last = model.index(2); const auto snapshot = model.member(9); QVERIFY(snapshot); model.onMemberRemoved("123", 7); QCOMPARE(last.row(), 1); QCOMPARE(model.nodeIdAt(last.row()), quint64(9)); const auto changed = row(9, false, 0, 0xffff); model.onMemberUpdated("123", reinterpret_cast(changed.constData())); QVERIFY(snapshot->online); QVERIFY(!model.member(9)->online); const auto added = row(10); model.onMemberUpdated("123", reinterpret_cast(added.constData())); QCOMPARE(model.rowCount(), 3); QCOMPARE(model.nodeIdAt(2), quint64(10)); QVERIFY(!model.member(7)); QCOMPARE(model.nodeIdAt(-1), quint64(0)); } void snapshotChangedSeesNewChannel() { MemberListModel model; model.setChannel("123", false); memberRequests.clear(); connect(&model, &MemberListModel::snapshotChanged, &model, [&] { model.refresh(); }); model.setChannel("456", false); QCOMPARE(memberRequests.size(), size_t(2)); for (const auto& request : memberRequests) QCOMPARE(QString::fromUtf8(request.ch_id), QString("456")); } void losingOwnerPolicyHidesDeletedRowsWithoutCore() { MemberListModel model; model.setChannel("123", true); auto bytes = row(7) + row(8); bytes[CHAT_MEMBER_DISPLAY_SIZE + 10] = CHAT_MEMBER_FLAG_DELETED; model.onMemberList("123", 2, reinterpret_cast(bytes.constData()), bytes.size()); QCOMPARE(model.rowCount(), 2); ready = false; model.setChannel("123", false); QCOMPARE(model.rowCount(), 1); QVERIFY(!model.member(8)); } void malformedUpdatesKeepExistingSnapshot() { MemberListModel model; model.setChannel("123", false); const auto bytes = row(7); model.onMemberList("123", 1, reinterpret_cast(bytes.constData()), bytes.size()); model.onMemberList("123", 2, reinterpret_cast(bytes.constData()), bytes.size()); model.onMemberList("123", -1, nullptr, -1); model.onMemberUpdated("123", nullptr); QCOMPARE(model.rowCount(), 1); QCOMPARE(model.nodeIdAt(0), quint64(7)); } void rejectedMemberRequestsReleaseArguments() { const auto before = u_get_allocated_count(); rejectOwned = true; MemberListModel model; model.setChannel("123", false); model.refresh(); model.refreshRtt(); QCOMPARE(ownedPosts, 3); QCOMPARE(u_get_allocated_count(), before); } void invalidChannelDoesNotReplaceVisibleSnapshotOrSendRequests() { MemberListModel model; model.setChannel("123", false); const auto bytes = row(7); model.onMemberList("123", 1, reinterpret_cast(bytes.constData()), bytes.size()); memberRequests.clear(); model.setChannel(QString(64, '1'), false); model.setChannel(QString::fromUtf8("123\0suffix", 10), false); QCOMPARE(model.rowCount(), 1); QCOMPARE(model.nodeIdAt(0), quint64(7)); QVERIFY(memberRequests.empty()); model.refresh(); QCOMPARE(memberRequests.size(), size_t(1)); QCOMPARE(QString::fromUtf8(memberRequests.back().ch_id), QString("123")); } void twoPanelsReceiveTheirRepliesAndSurviveOtherPanelDestruction() { AccountList first; first.show(); fill(first, "123", row(7)); QVERIFY(select(first, 0)); const auto firstRequest = requests.back(); auto second = std::make_unique(); second->show(); fill(*second, "456", row(8)); QVERIFY(select(*second, 0)); const auto secondRequest = requests.back(); deliver(reply(firstRequest, {7, 111})); QVERIFY(body(first).contains("000000000000006f")); deliver(reply(secondRequest, {8, 222})); QVERIFY(body(*second).contains("00000000000000de")); second.reset(); QVERIFY(QMetaObject::invokeMethod(&first, "onDetailTimerTick")); deliver(reply(requests.back(), {7, 333})); QVERIFY(body(first).contains("000000000000014d")); } void rejectedDetailRequestCanRetryImmediately() { AccountList panel; panel.show(); fill(panel, "123", row(7)); rejectOwned = true; QVERIFY(select(panel, 0)); QVERIFY(requests.empty()); rejectOwned = false; QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); QCOMPARE(requests.size(), size_t(1)); deliver(reply(requests.back(), {7, 8})); QVERIFY(body(panel).contains("BGP:")); } void detailEscapesSocketNamesAndFormatsIpv6Ports() { AccountList panel; panel.show(); fill(panel, "123", row(7)); QVERIFY(select(panel, 0)); auto bytes = reply(requests.back()); bytes.resize(CHAT_MEMBER_DETAIL_HEADER_SIZE + CHAT_MEMBER_DETAIL_LINK_SIZE); auto* data = reinterpret_cast(bytes.data()); data[29] = 1; auto* link = data + CHAT_MEMBER_DETAIL_HEADER_SIZE; memset(link, 0, CHAT_MEMBER_DETAIL_LINK_SIZE); link[0] = 1; link[1] = 3; link[2] = 1; link[3] = 1; link[5] = 6; link[21] = 1; link[22] = 0x35; link[23] = 5; link[24] = 6; link[40] = 2; link[41] = 0xbb; link[42] = 1; strcpy(reinterpret_cast(link + 43), ""); deliver(bytes); QVERIFY(body(panel).contains("[::1]:1333")); QVERIFY(body(panel).contains("[::2]:443")); QVERIFY(body(panel).contains("")); } void contextWithoutDatabaseUsesSnapshotAndOwnsConnectRequest() { AccountList panel; panel.resize(300, 600); panel.show(); fill(panel, "123", row(7)); QSignalSpy calls(&panel, &AccountList::callRequested); QVERIFY(context(panel, "Call peer")); QCOMPARE(calls.size(), 1); QCOMPARE(calls.first()[0].toString(), QString("123")); QCOMPARE(calls.first()[1].toULongLong(), quint64(7)); QVERIFY(!context(panel, "Rename")); QVERIFY(!context(panel, "Edit")); const auto before = u_get_allocated_count(); rejectOwned = true; QVERIFY(context(panel, "Connect")); QCOMPARE(u_get_allocated_count(), before); QVERIFY(connectRequests.empty()); rejectOwned = false; QVERIFY(context(panel, "Connect")); QCOMPARE(connectRequests.size(), size_t(1)); QCOMPARE(QString::fromUtf8(connectRequests.back().ch_id), QString("123")); QCOMPARE(connectRequests.back().node_id, uint64_t(7)); } void contextRevalidatesChannelMemberAndOnlineState() { AccountList panel; panel.resize(300, 600); panel.show(); fill(panel, "123", row(7)); QSignalSpy calls(&panel, &AccountList::callRequested); QVERIFY(context(panel, "Call peer", [&] { const auto offline = row(7, false); panel.onMemberUpdated("123", reinterpret_cast(offline.constData())); })); QCOMPARE(calls.size(), 0); fill(panel, "123", row(7)); QVERIFY(context(panel, "Call peer", [&] { fill(panel, "456", row(8)); })); QCOMPARE(calls.size(), 0); fill(panel, "123", row(7)); QVERIFY(context(panel, "Call peer", [&] { panel.onMemberRemoved("123", 7); })); QCOMPARE(calls.size(), 0); fill(panel, "123", row(7) + row(8)); QVERIFY(context(panel, "Call peer", [&] { fill(panel, "123", row(8) + row(7)); })); QCOMPARE(calls.size(), 1); QCOMPARE(calls.first()[1].toULongLong(), quint64(7)); } void destroyingPanelDuringContextMenuIsSafe() { auto panel = std::make_unique(); panel->show(); fill(*panel, "123", row(7)); QPointer weak(panel.get()); QVERIFY(!context(*panel, "Call peer", [&] { panel.reset(); })); QVERIFY(weak.isNull()); } void destroyingBorrowedDatabaseDisablesEditing() { auto database = std::make_unique(); AccountList panel(database.get()); panel.show(); fill(panel, "123", row(7)); database.reset(); QVERIFY(QMetaObject::invokeMethod(&panel, "onMemberDoubleClicked", Qt::DirectConnection, Q_ARG(QModelIndex, panel.model()->index(0)))); QVERIFY(!context(panel, "Edit")); } void unavailableCoreDiscardsPendingTokenBeforeRetry() { AccountList panel; panel.show(); fill(panel, "123", row(7)); QVERIFY(select(panel, 0)); const auto old = requests.back(); ready = false; QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); ready = true; QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); QCOMPARE(requests.size(), size_t(2)); deliver(reply(old, {111})); QVERIFY(!body(panel).contains("BGP:")); deliver(reply(requests.back(), {7, 222})); QVERIFY(body(panel).contains("00000000000000de")); } void malformedStoredAddressDoesNotReadOutsideBlob() { sqlite3* database = nullptr; QCOMPARE(sqlite3_open(":memory:", &database), SQLITE_OK); const auto close = qScopeGuard([&] { sqlite3_close(database); }); DbManager manager; AccountList panel(&manager); panel.show(); fill(panel, "123", row(7)); const char* schema = "CREATE TABLE nodes(node_id,last_seen_at,created_at);" "INSERT INTO nodes VALUES(7,0,0);" "CREATE TABLE node_addresses(node_id,family,protocol,address,port,socket_id,addr_type,nat_mapping,nat_inbound,nat_source);" "INSERT INTO node_addresses VALUES(7,6,2,x'01',555,1,1,0,0,0);" "INSERT INTO node_addresses VALUES(7,4,2,x'7f000001',444,2,1,0,0,0);"; QCOMPARE(sqlite3_exec(database, schema, nullptr, nullptr, nullptr), SQLITE_OK); manager.setDb(database); QVERIFY(select(panel, 0)); QVERIFY(body(panel).contains("127.0.0.1:444")); QVERIFY(!body(panel).contains(":555")); } void modelOwnsPresenceAndRttAndRefreshPreservesSelection() { AccountList panel; panel.resize(600, 800); panel.show(); fill(panel, "123", row(7, false, 0, 0xffff) + row(8)); QVERIFY(select(panel, 0)); QCOMPARE(requests.size(), size_t(1)); deliver(reply(requests.back())); // Физический транспорт UP не делает участника онлайн. QVERIFY(header(panel).contains("#9E9E9E")); QVERIFY(body(panel).contains("offline")); const auto live = row(7, true, 4, 93); panel.onMemberUpdated("123", reinterpret_cast(live.constData())); QVERIFY(header(panel).contains("#4CAF50")); QVERIFY(body(panel).contains("rtt=9.3ms")); deliver(reply(requests.back(), {7, 9})); QVERIFY(body(panel).contains("0000000000000009")); const auto other = row(7, false, 0, 0xffff); panel.onMemberUpdated("456", reinterpret_cast(other.constData())); QVERIFY(header(panel).contains("#4CAF50")); panel.refresh(); QCOMPARE(panel.model()->rowCount(), 2); QVERIFY(body(panel).contains("rtt=9.3ms")); const auto reordered = row(8) + live; panel.onMemberList("123", 2, reinterpret_cast(reordered.constData()), reordered.size()); QVERIFY(header(panel).contains("peer7")); QCOMPARE(panel.findChild()->currentIndex().row(), 1); } void aToBToADiscardsOldResponsesWithoutClearingNewRequest() { AccountList panel; panel.show(); fill(panel, "123", row(7) + row(8)); QVERIFY(select(panel, 0)); const auto first = requests.back(); QVERIFY(select(panel, 1)); const auto second = requests.back(); QVERIFY(select(panel, 0)); const auto third = requests.back(); QVERIFY(first.request_id < second.request_id && second.request_id < third.request_id); deliver(reply(first, {111})); deliver(reply(second, {222})); QVERIFY(!body(panel).contains("BGP:")); auto wrongGroup = third; wrongGroup.group_id = 456; deliver(reply(wrongGroup, {333})); QVERIFY(!body(panel).contains("BGP:")); deliver(reply(third, {7, 444})); QVERIFY(body(panel).contains("00000000000001bc")); const auto pending = requests.size(); QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); QCOMPARE(requests.size(), pending + 1); const auto oldChannel = requests.back(); fill(panel, "456", row(7)); QVERIFY(select(panel, 0)); const auto current = requests.back(); deliver(reply(oldChannel, {555})); QVERIFY(!body(panel).contains("BGP:")); deliver(reply(current, {7, 666})); QVERIFY(body(panel).contains("000000000000029a")); } void newMemberEventInvalidatesPendingPathEvenWhenFieldsAreEqual() { AccountList panel; panel.show(); const auto initial = row(7); fill(panel, "123", initial); QVERIFY(select(panel, 0)); const auto first = requests.back(); panel.onMemberUpdated("123", reinterpret_cast(initial.constData())); QCOMPARE(requests.size(), size_t(1)); deliver(reply(first, {111})); QCOMPARE(requests.size(), size_t(2)); QVERIFY(!body(panel).contains("BGP:")); const auto correct = reply(requests.back(), {7, 8}); deliver(correct.left(correct.size() - 1)); QVERIFY(!body(panel).contains("BGP:")); deliver(correct); QVERIFY(body(panel).contains("0000000000000008")); panel.onMemberRemoved("123", 7); QVERIFY(!header(panel).contains("peer7")); deliver(correct); QVERIFY(!body(panel).contains("BGP:")); } void errorTimeoutAndHiddenPanelReleaseOnlyTheirOwnRequests() { AccountList panel; panel.show(); fill(panel, "123", row(7)); QVERIFY(select(panel, 0)); deliver(reply(requests.back(), {}, CHAT_MEMBER_DETAIL_NOT_MEMBER)); QVERIFY(header(panel).contains("#4CAF50")); // Диагностическая ошибка не меняет live-снимок. QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); const auto expired = requests.back(); QTRY_VERIFY_WITH_TIMEOUT(requests.back().request_id != expired.request_id, 6500); const auto current = requests.back(); deliver(reply(expired, {111})); deliver(reply(current, {7, 8})); QVERIFY(body(panel).contains("BGP:")); QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); const auto hidden = requests.back(); panel.hide(); deliver(reply(hidden, {222})); panel.show(); QVERIFY(requests.back().request_id > hidden.request_id); deliver(reply(hidden, {222})); QVERIFY(!body(panel).contains("00000000000000de")); ready = false; panel.setChannel(QString()); deliver(reply(requests.back(), {333})); QCOMPARE(panel.model()->rowCount(), 0); QVERIFY(!body(panel).contains("BGP:")); } }; QTEST_MAIN(MemberDetailTest) #include "test_member_detail.moc"