/* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */ #include "../src/accountlist.h" #include "../src/accountdelegate.h" #include "../src/devgroupdialog.h" #include #include #include #include #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 #include #include extern "C" { #include "utun_instance.h" #include "chat/chat_core.h" #include "chat/chat_member.h" #include "chat/chat_devgroup.h" #include "member_files/protocol.h" #include "debug_config.h" #include "mem.h" } static UTUN_INSTANCE instance{}; static std::vector devgroupRequests; static gui_dm_messages_fn devgroupCallback; 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; static std::vector muteRequests; 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 node) { muteRequests.push_back(node); 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)); if (fn == chat_devgroup_trampoline) devgroupRequests.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 = {}) { auto* view = panel.findChild(); const QPoint point = view->visualRect(view->model()->index(0, 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(); muteRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true; devgroupRequests.clear(); debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); } void deviceInvitationOpensPinAutomaticallyAndReturnsEnteredPin() { QWidget window; devgroupRequests.clear(); initDevgroupUi(&window); QVERIFY(devgroupCallback && !devgroupRequests.empty()); auto respond = [](quint64 request, const QByteArray& json) { QByteArray bytes(9, 0); mf_put64(reinterpret_cast(bytes.data()), request); bytes[8] = 1; bytes += json; devgroupCallback(reinterpret_cast(bytes.constData()), bytes.size()); }; const QByteArray invite = "0123456789abcdef0123456789abcdef"; const QByteArray snapshot = "{\"group\":\"\",\"name\":\"\",\"invitations\":[{\"id\":\"" + invite + "\",\"peer\":\"0000000000000007\",\"channel\":\"123\",\"outgoing\":0,\"state\":1}]}"; respond(devgroupRequests.back().request_id, snapshot); auto* dialog = window.findChild("devgroupIncomingPin"); QVERIFY(dialog && dialog->isVisible()); auto* box = dialog->findChild(); QVERIFY(box); QVERIFY(!box->button(QDialogButtonBox::Ok)->isEnabled()); dialog->setTextValue("1234"); QVERIFY(box->button(QDialogButtonBox::Ok)->isEnabled()); dialog->accept(); QCOMPARE(devgroupRequests.back().action, CHAT_DEVGROUP_ACCEPT); QCOMPARE(QByteArray(devgroupRequests.back().text), invite); QCOMPARE(QByteArray(devgroupRequests.back().pin), QByteArray("1234")); // Неверный PIN возвращает waiting: поле появляется повторно, сетевые повторы его не дублируют. respond(devgroupRequests.back().request_id, snapshot); QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); dialog = window.findChild("devgroupIncomingPin"); QVERIFY(dialog && dialog->isVisible()); dialog->reject(); devgroupCallback(nullptr, 0); respond(devgroupRequests.back().request_id, snapshot); QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); QVERIFY(!window.findChild("devgroupIncomingPin")); } void addMyDeviceImmediatelyRequestsInvitationAndShowsPin() { QWidget parent; devgroupRequests.clear(); inviteMyDevice(&parent, "123", 7); QVERIFY(!devgroupRequests.empty()); const auto request = devgroupRequests.back(); QCOMPARE(request.action, CHAT_DEVGROUP_INVITE); QCOMPARE(request.group, uint64_t(123)); QCOMPARE(request.peer, uint64_t(7)); QByteArray bytes(9, 0); mf_put64(reinterpret_cast(bytes.data()), request.request_id); bytes[8] = 1; bytes += "{\"id\":\"0123456789abcdef0123456789abcdef\",\"pin\":\"1234\"}"; devgroupCallback(reinterpret_cast(bytes.constData()), bytes.size()); bool shown = false; for (auto* label : parent.findChildren()) shown |= label->text().contains("1234"); QVERIFY(shown); } void disconnectSelfUsesLeaveCommandAndDisablesButton() { QWidget parent; QPushButton button("Disconnect from my devices", &parent); devgroupRequests.clear(); disconnectMyDevice(&button); QVERIFY(!button.isEnabled()); QVERIFY(!devgroupRequests.empty()); QCOMPARE(devgroupRequests.back().action, CHAT_DEVGROUP_LEAVE); QCOMPARE(devgroupRequests.back().peer, uint64_t(0)); QByteArray bytes(9, 0); mf_put64(reinterpret_cast(bytes.data()), devgroupRequests.back().request_id); bytes[8] = 1; bytes += "{}"; devgroupCallback(reinterpret_cast(bytes.constData()), bytes.size()); QVERIFY(!button.isEnabled()); } 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 devgroupHeadersAreExpandableAndNeverRepresentADevice() { MemberListModel source; source.setChannel("123", false); GroupedMemberModel grouped(&source); QAbstractItemModelTester tester(&grouped, QAbstractItemModelTester::FailureReportingMode::QtTest); auto first = row(7, true), second = row(8, false), other = row(9, false); for (auto* bytes : {&first, &second}) { memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11); } auto data = first + second + other; source.onMemberList("123", 3, reinterpret_cast(data.constData()), data.size()); QCOMPARE(grouped.rowCount(), 2); QCOMPARE(grouped.sourceRow(0), -1); QVERIFY(grouped.index(0).data(GroupedMemberModel::GroupHeaderRole).toBool()); QVERIFY(grouped.index(0).data(MemberListModel::OnlineRole).toBool()); QCOMPARE(grouped.index(0).data(GroupedMemberModel::GroupBranchRole).toInt(), 0); QCOMPARE(grouped.index(1).data(GroupedMemberModel::GroupBranchRole).toInt(), 0); QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 4); QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(7)); QCOMPARE(source.nodeIdAt(grouped.sourceRow(2)), quint64(8)); QCOMPARE(grouped.index(1).data(GroupedMemberModel::GroupBranchRole).toInt(), 1); QCOMPARE(grouped.index(2).data(GroupedMemberModel::GroupBranchRole).toInt(), 2); QCOMPARE(grouped.index(3).data(GroupedMemberModel::GroupBranchRole).toInt(), 0); source.onMemberRemoved("123", 7); QVERIFY(!grouped.index(0).data(MemberListModel::OnlineRole).toBool()); QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(8)); QCOMPARE(grouped.index(1).data(GroupedMemberModel::GroupBranchRole).toInt(), 2); QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 2); QCOMPARE(grouped.index(1).data(GroupedMemberModel::GroupBranchRole).toInt(), 0); } void groupSelectionShowsOnlyLiveMembers() { AccountList panel; panel.resize(420, 600); panel.show(); auto self = row(21, true, 0, 0xffff, 255, 0, true), peer = row(7), foreign = row(9); for (auto* bytes : {&self, &peer}) { memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11); } memset(foreign.data() + 164, 43, 32); memcpy(foreign.data() + 196, "Other devices", 14); fill(panel, "123", peer + foreign + row(8) + self); auto* view = panel.findChild(); auto* devices = panel.findChild("devgroupDevices"); auto* detailHeader = panel.findChild("memberDetailHeader"); QVERIFY(devices && detailHeader); QVERIFY(!panel.findChild("devgroupRename")); QCOMPARE(view->model()->rowCount(), 3); auto clickGroup = [&](int row) { const QRect rect = view->visualRect(view->model()->index(row, 0)); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(rect.left() + 100, rect.center().y())); }; clickGroup(0); QCOMPARE(view->model()->rowCount(), 3); QVERIFY(devices->isVisible()); QVERIFY(!detailHeader->isVisible()); QCOMPARE(devices->model()->rowCount(), 2); QCOMPARE(devices->model()->index(0, 0).data(MemberListModel::NodeIdRole).toULongLong(), quint64(21)); QCOMPARE(devices->model()->index(1, 0).data(MemberListModel::NodeIdRole).toULongLong(), quint64(7)); QVERIFY(requests.empty()); QVERIFY(header(panel).isEmpty()); const QString screenshot = qEnvironmentVariable("UTUN_TEST_GROUP_DETAILS_SCREENSHOT"); if (!screenshot.isEmpty()) QVERIFY(panel.grab().save(screenshot)); QSignalSpy calls(&panel, &AccountList::callRequested); const QRect peerRect = devices->visualRect(devices->model()->index(1, 0)); QTest::mouseClick(devices->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(peerRect.right() - 49, peerRect.center().y())); QCOMPARE(calls.size(), 1); QCOMPARE(calls.last()[1].toULongLong(), quint64(7)); QVERIFY(!calls.last()[3].toBool()); clickGroup(0); const QRect groupRect = view->visualRect(view->model()->index(0, 0)); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(groupRect.left() + 12, groupRect.center().y())); QCOMPARE(view->model()->rowCount(), 5); QVERIFY(devices->isVisible()); QVERIFY(!detailHeader->isVisible()); QCOMPARE(devices->model()->rowCount(), 2); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(groupRect.left() + 12, groupRect.center().y())); QCOMPARE(view->model()->rowCount(), 3); QVERIFY(devices->isVisible()); panel.onMemberRemoved("123", 7); QCOMPARE(devices->model()->rowCount(), 1); clickGroup(1); QVERIFY(header(panel).isEmpty()); QVERIFY(!detailHeader->isVisible()); QCOMPARE(devices->model()->rowCount(), 1); const QPoint foreignMenu = view->visualRect(view->model()->index(1, 0)).center(); QVERIFY(QMetaObject::invokeMethod(&panel, "onMemberContextMenu", Qt::DirectConnection, Q_ARG(QPoint, foreignMenu))); QVERIFY(!QApplication::activePopupWidget()); QVERIFY(devgroupRequests.empty()); panel.onMemberRemoved("123", 9); QVERIFY(!devices->isVisible()); QVERIFY(detailHeader->isVisible()); clickGroup(0); QVERIFY(devices->isVisible()); const QRect memberRect = devices->visualRect(devices->model()->index(0, 0)); QTest::mouseClick(devices->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(memberRect.left() + 100, memberRect.center().y())); QVERIFY(!devices->isVisible()); QVERIFY(detailHeader->isVisible()); QVERIFY(!header(panel).isEmpty()); } void ownGroupContextRenameUsesCoreAndRejectsMembershipChange() { AccountList panel; panel.resize(420, 600); panel.show(); auto self = row(21, true, 0, 0xffff, 255, 0, true), peer = row(7); for (auto* bytes : {&self, &peer}) { memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11); } fill(panel, "123", peer + self); QVERIFY(context(panel, "Rename", [&] { QTimer::singleShot(0, &panel, [&] { auto* dialog = panel.findChild("devgroupRenameDialog"); QVERIFY(dialog); QCOMPARE(dialog->textValue(), QString("My devices")); dialog->setTextValue(QString(32, QChar(0x044f))); QVERIFY(!dialog->findChild()->button(QDialogButtonBox::Ok)->isEnabled()); dialog->setTextValue(QString::fromUtf8("Мои устройства")); dialog->accept(); }); })); QCOMPARE(devgroupRequests.size(), size_t(1)); QCOMPARE(devgroupRequests.back().action, CHAT_DEVGROUP_RENAME); QCOMPARE(QString::fromUtf8(devgroupRequests.back().text), QString::fromUtf8("Мои устройства")); QByteArray response(9, 0); mf_put64(reinterpret_cast(response.data()), devgroupRequests.back().request_id); response[8] = 1; response += "{}"; devgroupCallback(reinterpret_cast(response.constData()), response.size()); auto* view = panel.findChild(); const QRect rect = view->visualRect(view->model()->index(0, 0)); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(rect.left() + 100, rect.center().y())); QVERIFY(!panel.findChild("devgroupRename")); QVERIFY(context(panel, "Rename", [&] { QTimer::singleShot(0, &panel, [&] { auto* dialog = panel.findChild("devgroupRenameDialog"); QVERIFY(dialog); auto left = row(21, true, 0, 0xffff, 255, 0, true); panel.onMemberUpdated("123", reinterpret_cast(left.constData())); dialog->setTextValue("Wrong group"); dialog->accept(); }); })); QCOMPARE(devgroupRequests.size(), size_t(1)); } void selfIsFirstIncludingLateArrivalAndExpandedDeviceGroup() { debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); MemberListModel source; source.setChannel("123", false); GroupedMemberModel grouped(&source); QAbstractItemModelTester tester(&grouped, QAbstractItemModelTester::FailureReportingMode::QtTest); const auto initial = row(7) + row(8); source.onMemberList("123", 2, reinterpret_cast(initial.constData()), initial.size()); auto self = row(21, true, 0, 0xffff, 255, 0, true); source.onMemberUpdated("123", reinterpret_cast(self.constData())); QCOMPARE(source.nodeIdAt(grouped.sourceRow(0)), quint64(21)); QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(7)); QCOMPARE(source.nodeIdAt(grouped.sourceRow(2)), quint64(8)); auto peer = row(7); for (auto* bytes : {&self, &peer}) { memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11); } source.onMemberUpdated("123", reinterpret_cast(peer.constData())); source.onMemberUpdated("123", reinterpret_cast(self.constData())); QCOMPARE(grouped.rowCount(), 2); QVERIFY(grouped.index(0).data(GroupedMemberModel::GroupHeaderRole).toBool()); QCOMPARE(source.nodeIdAt(grouped.avatarSourceRow(0)), quint64(21)); QCOMPARE(grouped.index(0).data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(21)); QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 4); QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(21)); QCOMPARE(source.nodeIdAt(grouped.sourceRow(2)), quint64(7)); QCOMPARE(source.nodeIdAt(grouped.sourceRow(3)), quint64(8)); QCOMPARE(grouped.index(1).data(GroupedMemberModel::GroupBranchRole).toInt(), 1); QCOMPARE(grouped.index(2).data(GroupedMemberModel::GroupBranchRole).toInt(), 2); // Полный refresh с self в середине сохраняет раскрытие и порядок остальных. const auto refresh = row(8) + self + peer; source.onMemberList("123", 3, reinterpret_cast(refresh.constData()), refresh.size()); QCOMPARE(grouped.rowCount(), 4); QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(21)); QCOMPARE(source.nodeIdAt(grouped.sourceRow(2)), quint64(7)); source.onMemberRemoved("123", 21); QCOMPARE(grouped.rowCount(), 3); QCOMPARE(source.nodeIdAt(grouped.sourceRow(0)), quint64(8)); QCOMPARE(grouped.index(1).data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(7)); } void singleDeviceBranchIsVisible_data() { QTest::addColumn("dark"); QTest::newRow("light") << false; QTest::newRow("actual-dark-palette") << true; } void singleDeviceBranchIsVisible() { QFETCH(bool, dark); debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); AccountList panel; panel.resize(380, 240); QPalette palette = panel.palette(); palette.setColor(QPalette::Window, dark ? QColor(32, 32, 32) : QColor(240, 240, 240)); palette.setColor(QPalette::WindowText, dark ? QColor(220, 220, 220) : Qt::black); palette.setColor(QPalette::Base, dark ? QColor(24, 24, 24) : Qt::white); palette.setColor(QPalette::AlternateBase, dark ? QColor(38, 38, 38) : QColor(247, 247, 247)); palette.setColor(QPalette::Text, dark ? QColor(220, 220, 220) : Qt::black); palette.setColor(QPalette::Mid, dark ? QColor(30, 30, 30) : QColor(160, 160, 160)); panel.setPalette(palette); panel.show(); auto device = row(7); memset(device.data() + 164, 42, 32); memcpy(device.data() + 196, "My devices", 11); fill(panel, "123", device + row(8)); auto* view = panel.findChild(); QTest::qWait(10); const QRect header = view->visualRect(view->model()->index(0, 0)); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(header.left() + 12, header.center().y())); QCOMPARE(view->model()->rowCount(), 3); QCOMPARE(view->model()->index(1, 0).data(GroupedMemberModel::GroupBranchRole).toInt(), 2); const QRect child = view->visualRect(view->model()->index(1, 0)); const QImage image = view->viewport()->grab().toImage(); const QColor background = palette.color(QPalette::AlternateBase); auto contrast = [&](int x, int y) { return qAbs(image.pixelColor(x, y).lightness() - background.lightness()); }; DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "single device branch: dark=%d background=%s mid=%s text=%s vertical_contrast=%d corner_contrast=%d", dark, qPrintable(background.name()), qPrintable(palette.color(QPalette::Mid).name()), qPrintable(palette.color(QPalette::Text).name()), contrast(child.left() + 12, child.top() + 4), contrast(child.left() + 18, child.center().y())); const QString screenshot = qEnvironmentVariable("UTUN_TEST_SINGLE_GROUP_SCREENSHOT"); if (!screenshot.isEmpty() && dark) QVERIFY(panel.grab().save(screenshot)); QVERIFY2(contrast(child.left() + 12, child.top() + 4) >= 40, "Vertical branch must be visible against the row background"); QVERIFY2(contrast(child.left() + 18, child.center().y()) >= 40, "Final corner must be visible against the row background"); QCOMPARE(contrast(child.left() + 12, child.bottom() - 4), 0); // Линия заканчивается на единственном устройстве. } void devgroupControlsFollowFirstOnlineDeviceAndKeepFirstAvatar() { AccountList panel; panel.resize(380, 350); panel.show(); auto first = row(7, false), second = row(8), third = row(9); for (auto* bytes : {&first, &second, &third}) { memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11); } fill(panel, "123", first + second + third + row(10)); auto* view = panel.findChild(); auto* grouped = qobject_cast(view->model()); QVERIFY(grouped); QSignalSpy calls(&panel, &AccountList::callRequested), radioCalls(&panel, &AccountList::radioCallRequested); const auto group = grouped->index(0); QCOMPARE(group.data(Qt::DisplayRole).toString(), QString("My devices")); QCOMPARE(group.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(8)); QCOMPARE(grouped->avatarSourceRow(0), 0); QCOMPARE(grouped->sourceRow(0), -1); const auto avatar = qvariant_cast(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage(); QCOMPARE(avatar, qvariant_cast(panel.model()->index(0).data(Qt::DecorationRole)).pixmap(34, 34).toImage()); QTest::qWait(10); const QRect rect = view->visualRect(group); const QPoint call(rect.right() - 49, rect.center().y()), radio(rect.right() - 81, rect.center().y()); const QPoint mute(rect.right() - 17, rect.center().y()); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call); QCOMPARE(calls.size(), 1); QCOMPARE(calls.last()[1].toULongLong(), quint64(8)); QCOMPARE(calls.last()[2].toString(), QString("My devices")); QVERIFY(calls.last()[3].toBool()); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, radio); QCOMPARE(radioCalls.size(), 1); QCOMPARE(radioCalls.last()[1].toULongLong(), quint64(8)); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, mute); QCOMPARE(muteRequests.size(), size_t(1)); QCOMPARE(muteRequests.back(), uint64_t(8)); QCOMPARE(grouped->rowCount(), 2); // Кнопки не раскрывают группу. QTest::mousePress(view->viewport(), Qt::LeftButton, Qt::NoModifier, call); second[8] = 0; // Устройство уходит offline между press и release. panel.onMemberUpdated("123", reinterpret_cast(second.constData())); QTest::mouseRelease(view->viewport(), Qt::LeftButton, Qt::NoModifier, call); QCOMPARE(calls.size(), 1); QCOMPARE(group.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(9)); QCOMPARE(qvariant_cast(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage(), avatar); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call); QCOMPARE(calls.size(), 2); QCOMPARE(calls.last()[1].toULongLong(), quint64(9)); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(rect.left() + 12, rect.center().y())); QCOMPARE(grouped->rowCount(), 5); QVERIFY(grouped->index(0).data(GroupedMemberModel::GroupExpandedRole).toBool()); const QString screenshot = qEnvironmentVariable("UTUN_TEST_GROUP_SCREENSHOT"); if (!screenshot.isEmpty()) QVERIFY(panel.grab().save(screenshot)); third[8] = 0; panel.onMemberUpdated("123", reinterpret_cast(third.constData())); QCOMPARE(grouped->index(0).data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(0)); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call); QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, mute); QCOMPARE(calls.size(), 2); QCOMPARE(muteRequests.size(), size_t(1)); } void devgroupIndicatorsCombineDevicesEvenWhenFirstOnlineIsSelf() { MemberListModel source; source.setChannel("123", true); GroupedMemberModel grouped(&source); auto self = row(7, true, 0, 0xffff, 255, 0, true), busy = row(8), removed = row(9); for (auto* bytes : {&self, &busy, &removed}) { memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11); } const int active = CHAT_MEMBER_FLAG_RADIO | CHAT_MEMBER_FLAG_PHONE_KNOWN | CHAT_MEMBER_FLAG_PHONE_BUSY; busy[10] = char(active | CHAT_MEMBER_FLAG_RADIO_MUTED); removed[10] = char(active | CHAT_MEMBER_FLAG_DELETED); const auto bytes = self + busy + removed; source.onMemberList("123", 3, reinterpret_cast(bytes.constData()), bytes.size()); const auto header = grouped.index(0); QVERIFY(header.data(Qt::UserRole + 2).toBool()); QCOMPARE(header.data(Qt::UserRole + 3).toInt(), active); // mute относится только к цели кнопок. AccountDelegate delegate; QStyleOptionViewItem option; option.rect = QRect(0, 0, 340, MemberListModel::kRowHeight); const QColor background("#FFFFFF"); option.palette.setColor(QPalette::AlternateBase, background); auto image = [&] { QImage result(option.rect.size(), QImage::Format_RGB32); result.fill(background); QPainter painter(&result); delegate.paint(&painter, option, header); painter.end(); return result; }; auto colors = [](const QImage& image, int rightOffset, const QColor& color) { int count = 0; for (int y = 0; y < image.height(); ++y) for (int x = image.width() - rightOffset - 1; x < image.width() - rightOffset + 27; ++x) count += image.pixelColor(x, y) == color; return count; }; const auto activeImage = image(); QVERIFY(colors(activeImage, 95, QColor("#4CAF50")) > 0); QVERIFY(colors(activeImage, 63, QColor("#E53935")) > 0); QVERIFY(colors(activeImage, 31, QColor("#9E9E9E")) > 0); QSignalSpy calls(&delegate, &AccountDelegate::callRequested), toggles(&delegate, &AccountDelegate::groupToggleRequested); for (int offset : {81, 49, 17}) { const QPoint point(option.rect.right() - offset, option.rect.center().y()); QMouseEvent press(QEvent::MouseButtonPress, point, point, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QMouseEvent release(QEvent::MouseButtonRelease, point, point, Qt::LeftButton, Qt::NoButton, Qt::NoModifier); delegate.editorEvent(&press, &grouped, option, header); delegate.editorEvent(&release, &grouped, option, header); } QCOMPARE(calls.size(), 1); QCOMPARE(toggles.size(), 0); // Заголовок вызывает другие свои устройства; mute self не действует. busy[10] = CHAT_MEMBER_FLAG_PHONE_KNOWN; source.onMemberUpdated("123", reinterpret_cast(busy.constData())); QCOMPARE(header.data(Qt::UserRole + 3).toInt(), int(CHAT_MEMBER_FLAG_PHONE_KNOWN)); QCOMPARE(colors(image(), 63, QColor("#E53935")), 0); // Удалённый busy-аккаунт не влияет на группу. QCOMPARE(colors(image(), 95, QColor("#4CAF50")), 0); // self не окрашивает выключенную рацию в зелёный. self[8] = 0; busy[8] = 0; source.onMemberUpdated("123", reinterpret_cast(self.constData())); source.onMemberUpdated("123", reinterpret_cast(busy.constData())); QVERIFY(!header.data(MemberListModel::OnlineRole).toBool()); const auto offlineImage = image(); for (int offset : {95, 63}) QCOMPARE(colors(offlineImage, offset, background), 28 * offlineImage.height()); } 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" void gui_bridge_set_devgroup_result_cb(gui_dm_messages_fn cb) { devgroupCallback = cb; }