/* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */ #include "../src/accountlist.h" #include "../src/memberlistmodel.h" #include "../transport/gui_bridge.h" #include "../transport/member_edit.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; } // Сохранение диалогов проверяет test_member_dialogs; здесь такие команды не ожидаются. bool gui_bridge_save_member(sqlite3*, const QString&, quint64, const QString&, const QByteArray&, QObject*, std::function) { QTest::qFail("Unexpected member save in member detail test", __FILE__, __LINE__); return false; } bool gui_bridge_transfer_admin(sqlite3*, const QString&, quint64) { QTest::qFail("Unexpected admin transfer in member detail test", __FILE__, __LINE__); return false; } static QByteArray row(quint64 node, bool online = true, int up = 5, int rtt = 30, int nat = 255, int intermediaries = 0, bool self = false) { 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 = nat; member.intermediaries = intermediaries; member.is_self = self; 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 actualRouteText_data() { QTest::addColumn("up"); QTest::addColumn("hops"); QTest::addColumn("localNat"); QTest::addColumn("peerNat"); QTest::addColumn("rtt"); QTest::addColumn("online"); QTest::addColumn("compact"); QTest::addColumn("detail"); QTest::newRow("bgp-one-public-address") << 4 << 1 << 2 << 1 << 50 << true << "B1, RTT=5ms" << "BGP(1 hop) RTT=5ms"; QTest::newRow("bgp-two") << 4 << 2 << 2 << 1 << 50 << true << "B2, RTT=5ms" << "BGP(2 hop) RTT=5ms"; QTest::newRow("direct-both-nats") << 5 << 0 << 2 << 3 << 50 << true << "DIR, RTT=5ms" << "DIR(EIM-STRICT) RTT=5ms"; QTest::newRow("direct-reverse-order") << 5 << 0 << 3 << 2 << 93 << true << "DIR, RTT=9.3ms" << "DIR(STRICT-EIM) RTT=9.3ms"; QTest::newRow("direct-unknown-nat-rtt") << 5 << 0 << 1 << 255 << 65535 << true << "DIR" << "DIR(NONE-?)"; QTest::newRow("bgp-unknown-rtt") << 4 << 2 << 1 << 1 << 65535 << true << "B2" << "BGP(2 hop)"; QTest::newRow("offline-public-address") << 5 << 0 << 1 << 1 << 50 << false << "offline" << "offline"; QTest::newRow("indirect-session") << 2 << 1 << 2 << 3 << 50 << true << "IND, RTT=5ms" << "IND RTT=5ms"; } void actualRouteText() { QFETCH(int, up); QFETCH(int, hops); QFETCH(int, localNat); QFETCH(int, peerNat); QFETCH(int, rtt); QFETCH(bool, online); QFETCH(QString, compact); QFETCH(QString, detail); AccountList panel; panel.show(); fill(panel, "123", row(21, true, 0, 65535, localNat, 0, true) + row(7, online, up, rtt, peerNat, hops)); QCOMPARE(panel.model()->data(panel.model()->index(1), Qt::UserRole + 1).toString(), compact); QVERIFY(select(panel, 1)); QCOMPARE(body(panel).section('\n', 0, 0), detail); } void localNatChangeUpdatesSelectedPeer() { AccountList panel; panel.show(); fill(panel, "123", row(21, true, 0, 65535, 2, 0, true) + row(7, true, 5, 50, 3)); QVERIFY(select(panel, 1)); QCOMPARE(body(panel).section('\n', 0, 0), QString("DIR(EIM-STRICT) RTT=5ms")); size_t previousRequests = requests.size(); const auto changed = row(21, true, 0, 65535, 1, 0, true); panel.onMemberUpdated("123", reinterpret_cast(changed.constData())); QCOMPARE(body(panel).section('\n', 0, 0), QString("DIR(NONE-STRICT) RTT=5ms")); QCOMPARE(requests.size(), previousRequests); } 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[4] = 6; link[20] = 1; link[21] = 0x35; link[22] = 5; link[23] = 6; link[39] = 2; link[40] = 0xbb; link[41] = 1; strcpy(reinterpret_cast(link + 42), ""); strcpy(reinterpret_cast(link + 62), "TCP+REALITY"); deliver(bytes); QVERIFY(body(panel).contains("[::1]:1333")); QVERIFY(body(panel).contains("[::2]:443")); QVERIFY(body(panel).contains("")); } void bleHasNoIpPortsAndUsesPhysicalDirection() { for (int direction = 0; direction < 3; direction++) { 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] = direction; link[1] = 3; link[2] = 1; strcpy(reinterpret_cast(link + 62), "BLE"); deliver(bytes); const QString text = body(panel); QVERIFY(text.contains("BLE")); QVERIFY(!text.contains("UDP")); QVERIFY(!text.contains(":0")); QCOMPARE(text.contains("▶"), direction == 1); QCOMPARE(text.contains("◀"), direction == 2); // Неверное направление или имя в пакете не заменяет предыдущую карточку. QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); auto invalid = bytes; auto* header = reinterpret_cast(invalid.data()); mf_put64(header, requests.back().request_id); header[CHAT_MEMBER_DETAIL_HEADER_SIZE] = 3; deliver(invalid); QCOMPARE(body(panel), text); } } 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"