794 lines
50 KiB
C++
794 lines
50 KiB
C++
/* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */
|
|
#include "../src/accountlist.h"
|
|
#include "../src/accountdelegate.h"
|
|
#include "../src/devgroupdialog.h"
|
|
#include <QInputDialog>
|
|
#include <QDialogButtonBox>
|
|
#include <QLabel>
|
|
#include <QPushButton>
|
|
#include "../src/memberlistmodel.h"
|
|
#include "../transport/gui_bridge.h"
|
|
#include "../transport/member_edit.h"
|
|
#include "../db/db_manager.h"
|
|
#include <QtTest>
|
|
#include <QListView>
|
|
#include <QAbstractItemModelTester>
|
|
#include <QMenu>
|
|
#include <QPainter>
|
|
#include <QMouseEvent>
|
|
#include <QScopeGuard>
|
|
#include <memory>
|
|
#include <functional>
|
|
#include <vector>
|
|
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<chat_devgroup_request> devgroupRequests;
|
|
static gui_dm_messages_fn devgroupCallback;
|
|
static std::vector<chat_member_detail_req> requests;
|
|
static gui_member_detail_fn detailCallback;
|
|
static bool ready = true;
|
|
static bool rejectOwned = false;
|
|
static int ownedPosts = 0;
|
|
static std::vector<chat_member_list_req> memberRequests;
|
|
static std::vector<chat_connect_node_req> connectRequests;
|
|
static std::vector<uint64_t> 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<chat_member_detail_req*>(arg));
|
|
if (fn == chat_core_request_member_list_trampoline || fn == chat_core_request_member_rtt_trampoline)
|
|
memberRequests.push_back(*static_cast<chat_member_list_req*>(arg));
|
|
if (fn == chat_core_connect_node_trampoline) connectRequests.push_back(*static_cast<chat_connect_node_req*>(arg));
|
|
if (fn == chat_devgroup_trampoline) devgroupRequests.push_back(*static_cast<chat_devgroup_request*>(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<void(int)>) {
|
|
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<uint8_t*>(bytes.data()));
|
|
return bytes;
|
|
}
|
|
|
|
static QByteArray reply(const chat_member_detail_req& req, const QList<quint64>& hops = {}, int result = 0) {
|
|
QByteArray bytes(CHAT_MEMBER_DETAIL_HEADER_SIZE + hops.size() * 8, 0);
|
|
auto* data = reinterpret_cast<uint8_t*>(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<const uint8_t*>(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<const uint8_t*>(bytes.constData()), bytes.size());
|
|
}
|
|
|
|
class MemberDetailTest : public QObject {
|
|
Q_OBJECT
|
|
static QString body(AccountList& p) { return p.findChild<QTextEdit*>("memberDetailText")->toPlainText(); }
|
|
static QString header(AccountList& p) { return p.findChild<QLabel*>("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<void()>& change = {}) {
|
|
auto* view = panel.findChild<QListView*>();
|
|
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<QMenu*>(QApplication::activePopupWidget())) menu->close();
|
|
});
|
|
QObject::connect(&choose, &QTimer::timeout, &choose, [&] {
|
|
auto* menu = qobject_cast<QMenu*>(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<uint8_t*>(bytes.data()), request); bytes[8] = 1; bytes += json;
|
|
devgroupCallback(reinterpret_cast<const uint8_t*>(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<QInputDialog*>("devgroupIncomingPin"); QVERIFY(dialog && dialog->isVisible());
|
|
auto* box = dialog->findChild<QDialogButtonBox*>(); 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<QInputDialog*>("devgroupIncomingPin"); QVERIFY(dialog && dialog->isVisible());
|
|
dialog->reject();
|
|
devgroupCallback(nullptr, 0); respond(devgroupRequests.back().request_id, snapshot);
|
|
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
|
|
QVERIFY(!window.findChild<QInputDialog*>("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<uint8_t*>(bytes.data()), request.request_id); bytes[8] = 1;
|
|
bytes += "{\"id\":\"0123456789abcdef0123456789abcdef\",\"pin\":\"1234\"}";
|
|
devgroupCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
|
|
bool shown = false; for (auto* label : parent.findChildren<QLabel*>()) 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<uint8_t*>(bytes.data()), devgroupRequests.back().request_id); bytes[8] = 1;
|
|
bytes += "{}"; devgroupCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
|
|
QVERIFY(!button.isEnabled());
|
|
}
|
|
|
|
void actualRouteText_data() {
|
|
QTest::addColumn<int>("up"); QTest::addColumn<int>("hops"); QTest::addColumn<int>("localNat"); QTest::addColumn<int>("peerNat");
|
|
QTest::addColumn<int>("rtt"); QTest::addColumn<bool>("online"); QTest::addColumn<QString>("compact"); QTest::addColumn<QString>("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<const uint8_t*>(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<const uint8_t*>(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<QListView*>(); auto* devices = panel.findChild<QListView*>("devgroupDevices");
|
|
auto* detailHeader = panel.findChild<QLabel*>("memberDetailHeader");
|
|
QVERIFY(devices && detailHeader); QVERIFY(!panel.findChild<QPushButton*>("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<QInputDialog*>("devgroupRenameDialog"); QVERIFY(dialog);
|
|
QCOMPARE(dialog->textValue(), QString("My devices"));
|
|
dialog->setTextValue(QString(32, QChar(0x044f)));
|
|
QVERIFY(!dialog->findChild<QDialogButtonBox*>()->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<uint8_t*>(response.data()), devgroupRequests.back().request_id); response[8] = 1;
|
|
response += "{}"; devgroupCallback(reinterpret_cast<const uint8_t*>(response.constData()), response.size());
|
|
auto* view = panel.findChild<QListView*>();
|
|
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<QPushButton*>("devgroupRename"));
|
|
QVERIFY(context(panel, "Rename", [&] {
|
|
QTimer::singleShot(0, &panel, [&] {
|
|
auto* dialog = panel.findChild<QInputDialog*>("devgroupRenameDialog"); QVERIFY(dialog);
|
|
auto left = row(21, true, 0, 0xffff, 255, 0, true);
|
|
panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(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<const uint8_t*>(initial.constData()), initial.size());
|
|
auto self = row(21, true, 0, 0xffff, 255, 0, true);
|
|
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(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<const uint8_t*>(peer.constData()));
|
|
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(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<const uint8_t*>(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<bool>("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<QListView*>();
|
|
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<QListView*>();
|
|
auto* grouped = qobject_cast<GroupedMemberModel*>(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<QIcon>(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage();
|
|
QCOMPARE(avatar, qvariant_cast<QIcon>(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<const uint8_t*>(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<QIcon>(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<const uint8_t*>(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<const uint8_t*>(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<const uint8_t*>(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<const uint8_t*>(self.constData()));
|
|
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(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<const uint8_t*>(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<const uint8_t*>(changed.constData()));
|
|
QVERIFY(snapshot->online); QVERIFY(!model.member(9)->online);
|
|
const auto added = row(10);
|
|
model.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(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<const uint8_t*>(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<const uint8_t*>(bytes.constData()), bytes.size());
|
|
model.onMemberList("123", 2, reinterpret_cast<const uint8_t*>(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<const uint8_t*>(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<AccountList>(); 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<uint8_t*>(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<char*>(link + 42), "<sock&>");
|
|
strcpy(reinterpret_cast<char*>(link + 62), "TCP+REALITY");
|
|
deliver(bytes);
|
|
QVERIFY(body(panel).contains("[::1]:1333")); QVERIFY(body(panel).contains("[::2]:443"));
|
|
QVERIFY(body(panel).contains("<sock&>"));
|
|
}
|
|
|
|
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<uint8_t*>(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<char*>(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<uint8_t*>(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<const uint8_t*>(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<AccountList>(); panel->show(); fill(*panel, "123", row(7));
|
|
QPointer<AccountList> weak(panel.get());
|
|
QVERIFY(!context(*panel, "Call peer", [&] { panel.reset(); }));
|
|
QVERIFY(weak.isNull());
|
|
}
|
|
|
|
void destroyingBorrowedDatabaseDisablesEditing() {
|
|
auto database = std::make_unique<DbManager>();
|
|
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<const uint8_t*>(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<const uint8_t*>(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<const uint8_t*>(reordered.constData()), reordered.size());
|
|
QVERIFY(header(panel).contains("peer7"));
|
|
QCOMPARE(panel.findChild<QListView*>()->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<const uint8_t*>(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; }
|