555 lines
33 KiB
C++
555 lines
33 KiB
C++
/* Desktop MainWindow/MessageList/delegate/SQLite/bridge: отказы, отмена, OOM и привязка к владельцу.
|
|
* Native trampoline исполняется без подмены; пустой каталог CHAT даёт штатный FINISHED/UNAVAILABLE для START. */
|
|
#include "../src/mainwindow.h"
|
|
#include "../transport/gui_bridge.h"
|
|
#include "../transport/utun_node.h"
|
|
#include "../db/db_manager.h"
|
|
#include "../src/inputbar.h"
|
|
#include "../src/messagedelegate.h"
|
|
#include "../src/messagelist.h"
|
|
#include "../src/sound_manager.h"
|
|
#include <QtTest>
|
|
#include <QTemporaryDir>
|
|
#include <QFile>
|
|
#include <QTextEdit>
|
|
#include <QListView>
|
|
#include <QStandardItemModel>
|
|
#include <QPainter>
|
|
#include <QScopeGuard>
|
|
#include <atomic>
|
|
#include <thread>
|
|
#include <mutex>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <cstring>
|
|
extern "C" {
|
|
#include "utun_instance.h"
|
|
#include "chat/chat_core.h"
|
|
#include "chat/chat_event.h"
|
|
#include "radio/radio_call.h"
|
|
#include "dm/dm_core.h"
|
|
#include "media_async/attachment_send.h"
|
|
#include "audio_compressor.h"
|
|
#include "mem.h"
|
|
#include "debug_config.h"
|
|
}
|
|
|
|
static UTUN_INSTANCE* executingCore = nullptr;
|
|
static bool bindingValid = true;
|
|
static QList<radio_call_request> observed;
|
|
static QMap<quint64, bool> submissions;
|
|
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
#include "miniaudio.h"
|
|
static std::atomic<bool> watching{false};
|
|
static std::atomic<int> failAllocation{0};
|
|
static std::mutex allocationMutex;
|
|
static std::map<void*, const char*> outstanding;
|
|
static void track(void* pointer, const char* source) {
|
|
if (!pointer || !watching || (!strstr(source, "/gui_bridge_impl.cpp:") && !strstr(source, "/mainwindow.cpp:") &&
|
|
!strstr(source, "/messagelist.cpp:") && !strstr(source, "/messagefeedback.cpp:") &&
|
|
!strstr(source, "/test_desktop_commands.cpp:"))) return;
|
|
std::lock_guard<std::mutex> lock(allocationMutex); outstanding.emplace(pointer, source);
|
|
}
|
|
extern "C" void* __real_u_malloc_impl(uint32_t, const char*);
|
|
extern "C" void* __wrap_u_malloc_impl(uint32_t bytes, const char* source) {
|
|
void* pointer = __real_u_malloc_impl(bytes, source); track(pointer, source); return pointer;
|
|
}
|
|
extern "C" void* __real_u_calloc_impl(uint32_t, uint32_t, const char*);
|
|
extern "C" void* __wrap_u_calloc_impl(uint32_t count, uint32_t bytes, const char* source) {
|
|
if (failAllocation && strstr(source, "/gui_bridge_impl.cpp:") && --failAllocation == 0) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "[INJECT] radio command allocation failure at %s", source); return nullptr;
|
|
}
|
|
void* pointer = __real_u_calloc_impl(count, bytes, source); track(pointer, source); return pointer;
|
|
}
|
|
extern "C" void __real_u_free_impl(void*, const char*);
|
|
extern "C" void __wrap_u_free_impl(void* pointer, const char* source) {
|
|
{ std::lock_guard<std::mutex> lock(allocationMutex); outstanding.erase(pointer); }
|
|
__real_u_free_impl(pointer, source);
|
|
}
|
|
extern "C" void __real_radio_call_trampoline(void*);
|
|
extern "C" void __wrap_radio_call_trampoline(void* arg) {
|
|
auto* request = static_cast<radio_call_request*>(arg);
|
|
if (request->inst != executingCore) {
|
|
bindingValid = false;
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Radio command binding mismatch id=%llu request_inst=%p executing_core=%p",
|
|
(unsigned long long)request->id, static_cast<void*>(request->inst), static_cast<void*>(executingCore));
|
|
}
|
|
observed.append(*request);
|
|
__real_radio_call_trampoline(arg);
|
|
}
|
|
// Настоящий mixer остаётся включённым; null backend устраняет зависимость от аппаратных устройств.
|
|
extern "C" ma_result __real_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context*);
|
|
extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config* config, ma_context* context) {
|
|
const ma_backend backend = ma_backend_null;
|
|
return __real_ma_context_init(&backend, 1, config, context);
|
|
}
|
|
// Инъекции сохраняют настоящий finalize, чтобы даже ошибочный результат не оставлял statement.
|
|
static const char* sqlMatch = nullptr;
|
|
static int sqlStage = 0, sqlCalls = 0;
|
|
static bool failSql(sqlite3_stmt* statement, int stage) {
|
|
const char* sql = sqlite3_sql(statement);
|
|
if (sqlStage != stage || !sqlMatch || !sql || !strstr(sql, sqlMatch) || --sqlCalls) return false;
|
|
sqlStage = 0; DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "[INJECT] SQLite stage=%d sql=%s", stage, sql); return true;
|
|
}
|
|
extern "C" int __real_sqlite3_step(sqlite3_stmt*);
|
|
extern "C" int __wrap_sqlite3_step(sqlite3_stmt* statement) {
|
|
return failSql(statement, 1) ? SQLITE_IOERR : __real_sqlite3_step(statement);
|
|
}
|
|
extern "C" int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void(*)(void*));
|
|
extern "C" int __wrap_sqlite3_bind_text(sqlite3_stmt* statement, int index, const char* text, int bytes, void(*destroy)(void*)) {
|
|
return failSql(statement, 2) ? SQLITE_NOMEM : __real_sqlite3_bind_text(statement, index, text, bytes, destroy);
|
|
}
|
|
extern "C" int __real_sqlite3_finalize(sqlite3_stmt*);
|
|
extern "C" int __wrap_sqlite3_finalize(sqlite3_stmt* statement) {
|
|
const bool fail = failSql(statement, 3);
|
|
const int rc = __real_sqlite3_finalize(statement); return fail ? SQLITE_IOERR : rc;
|
|
}
|
|
#endif
|
|
|
|
struct DesktopCore {
|
|
QTemporaryDir directory;
|
|
UASYNC* ua = uasync_create();
|
|
UTUN_INSTANCE* inst = nullptr;
|
|
unsigned results = 0;
|
|
bool resultValid = true;
|
|
~DesktopCore() {
|
|
gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr);
|
|
if (ua) uasync_mark_stopped(ua);
|
|
if (inst) utun_instance_destroy(inst);
|
|
if (ua) uasync_destroy(ua, 0);
|
|
}
|
|
bool start() {
|
|
if (!ua || !directory.isValid()) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "fixture: no uasync/config directory"); return false;
|
|
}
|
|
const QByteArray config = "[global]\nauto_sockets=no\ndb_path=" + directory.path().toUtf8() +
|
|
"\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n"
|
|
"member_files_enabled=0\nlocal_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n";
|
|
const QString path = directory.filePath("utun.cfg"); QFile file(path);
|
|
if (!file.open(QIODevice::WriteOnly) || file.write(config) != config.size()) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "fixture: config write failed: %s", qPrintable(file.errorString())); return false;
|
|
}
|
|
file.close(); inst = utun_instance_create(ua, path.toUtf8().constData());
|
|
if (!inst || utun_core_start(inst) || chat_service_start(inst)) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "fixture: core/chat startup failed"); return false;
|
|
}
|
|
inst->test_user_ptr = this;
|
|
chat_event_set_handler(inst, [](UTUN_INSTANCE* instance, int type, const uint8_t* data, int len) {
|
|
if (type == CHAT_EVT_MSG_SUBMITTED) gui_bridge_post(type, data, len);
|
|
if (type != CHAT_EVT_RADIO_CALL) return;
|
|
auto* core = static_cast<DesktopCore*>(instance->test_user_ptr); ++core->results;
|
|
core->resultValid &= len == 27 && data[24] == RADIO_CALL_FINISHED && data[25] == RADIO_CALL_UNAVAILABLE && !data[26];
|
|
});
|
|
attach(); poll(); return true;
|
|
}
|
|
void attach() { uasync_mark_running(ua); gui_bridge_set_uasync(ua); gui_bridge_set_inst(inst); }
|
|
void poll() { executingCore = inst; uasync_poll(ua, 0); executingCore = nullptr; }
|
|
};
|
|
static unsigned pending(UASYNC* ua) {
|
|
unsigned count = 0;
|
|
for (auto* task = ua->posted_tasks_head; task; task = task->next) ++count;
|
|
return count;
|
|
}
|
|
|
|
class DesktopCommandsTest : public QObject {
|
|
Q_OBJECT
|
|
private slots:
|
|
void initTestCase() {
|
|
qApp->setQuitOnLastWindowClosed(false);
|
|
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); gui_bridge_init();
|
|
debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_DEBUG);
|
|
debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG);
|
|
}
|
|
void init() {
|
|
observed.clear(); submissions.clear(); bindingValid = true;
|
|
gui_bridge_set_msg_submitted_cb([](const uint8_t* data, int len) {
|
|
if (len != 9) return;
|
|
quint64 id = 0; for (int i = 0; i < 8; ++i) id |= quint64(data[i]) << (8 * i);
|
|
submissions.insert(id, data[8] != 0);
|
|
});
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
watching = false; failAllocation = 0; sqlMatch = nullptr; sqlStage = 0; sqlCalls = 0;
|
|
#endif
|
|
}
|
|
void cleanup() {
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
failAllocation = 0; sqlStage = 0; sqlMatch = nullptr;
|
|
#endif
|
|
gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr);
|
|
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
|
|
QCoreApplication::processEvents(); gui_bridge_set_msg_submitted_cb(nullptr);
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
watching = false;
|
|
std::lock_guard<std::mutex> lock(allocationMutex);
|
|
for (const auto& item : outstanding)
|
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Unfreed radio command ptr=%p source=%s", item.first, item.second);
|
|
QCOMPARE(outstanding.size(), size_t(0));
|
|
#endif
|
|
}
|
|
void imagePreviewPreparedOutsidePaint() {
|
|
QTemporaryDir directory; QVERIFY(directory.isValid());
|
|
const QString path = directory.filePath("image.png");
|
|
QImage original(1200, 1800, QImage::Format_ARGB32); original.fill(Qt::red); QVERIFY(original.save(path));
|
|
MessageList history; history.resize(600, 500); history.show();
|
|
auto* model = history.findChild<QStandardItemModel*>(); QVERIFY(model);
|
|
auto* view = history.findChild<QListView*>(); QVERIFY(view);
|
|
auto* item = new QStandardItem;
|
|
item->setData("image/png", MsgContentTypeRole); item->setData(path, MsgVoiceFileRole);
|
|
item->setData(1200, MsgImageWidthRole); item->setData(1800, MsgImageHeightRole);
|
|
item->setData(2, MsgFileDownloadStateRole); model->appendRow(item);
|
|
QTRY_VERIFY_WITH_TIMEOUT(!item->data(MsgImagePreviewRole).value<QImage>().isNull(), 3000);
|
|
const QImage preview = item->data(MsgImagePreviewRole).value<QImage>();
|
|
QVERIFY(preview.width() <= 600 && preview.height() <= 600);
|
|
QCOMPARE(preview.width() * 3, preview.height() * 2); QVERIFY(!item->data(MsgImageErrorRole).toBool());
|
|
QVERIFY(QFile::remove(path)); // Paint обязан использовать подготовленное превью даже без файла на диске.
|
|
QImage canvas(600, 400, QImage::Format_ARGB32); canvas.fill(Qt::black);
|
|
QStyleOptionViewItem option; option.initFrom(view); option.rect = canvas.rect();
|
|
QPainter painter(&canvas); view->itemDelegate()->paint(&painter, option, model->index(0, 0)); painter.end();
|
|
int red = 0;
|
|
for (int y = 0; y < canvas.height(); ++y) for (int x = 0; x < canvas.width(); ++x) {
|
|
const QColor color = canvas.pixelColor(x, y);
|
|
if (color.red() > 220 && color.green() < 30 && color.blue() < 30) ++red;
|
|
}
|
|
QVERIFY(red > 1000);
|
|
}
|
|
|
|
void boundCommand_data() {
|
|
QTest::addColumn<QString>("scenario");
|
|
for (const auto& name : {"unavailable", "stopped", "cancelled", "executed"}) QTest::newRow(name) << QString(name);
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
QTest::newRow("queue-oom") << QString("queue-oom");
|
|
#endif
|
|
}
|
|
void boundCommand() {
|
|
QFETCH(QString, scenario); DesktopCore core; QVERIFY(core.start());
|
|
if (scenario == "unavailable") gui_bridge_set_inst(nullptr);
|
|
if (scenario == "stopped") uasync_mark_stopped(core.ua);
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
watching = true; failAllocation = scenario == "queue-oom" ? 1 : 0;
|
|
#endif
|
|
int executed = 0; bool valid = false;
|
|
struct Command { UTUN_INSTANCE* inst; int* executed; bool* valid; };
|
|
auto* request = static_cast<Command*>(u_calloc(1, sizeof(Command))); QVERIFY(request);
|
|
request->executed = &executed; request->valid = &valid;
|
|
const int rc = gui_bridge_post_bound_fn([](void* arg) {
|
|
auto* command = static_cast<Command*>(arg); ++*command->executed;
|
|
*command->valid = command->inst && command->inst == executingCore; u_free(command);
|
|
}, request, &request->inst);
|
|
const bool accepted = scenario == "executed" || scenario == "cancelled";
|
|
QCOMPARE(rc, accepted ? 0 : -1);
|
|
if (rc) { QVERIFY(!request->inst); u_free(request); }
|
|
if (scenario == "executed") core.poll();
|
|
else gui_bridge_set_inst(nullptr);
|
|
QCOMPARE(executed, scenario == "executed" ? 1 : 0);
|
|
if (executed) QVERIFY(valid);
|
|
QCOMPARE(pending(core.ua), 0u);
|
|
}
|
|
void attachmentOwnership_data() {
|
|
QTest::addColumn<QString>("scenario"); QTest::addColumn<bool>("temporary");
|
|
QStringList names{"unavailable", "stopped", "cancelled", "executed"};
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
names << "queue-oom";
|
|
#endif
|
|
for (const auto& name : names) for (bool temporary : {false, true})
|
|
QTest::newRow(qPrintable(name + (temporary ? "-owned-source" : "-borrowed-source"))) << name << temporary;
|
|
}
|
|
void attachmentOwnership() {
|
|
QFETCH(QString, scenario); QFETCH(bool, temporary); std::atomic<int> releases{0}; DesktopCore core; QVERIFY(core.start());
|
|
const quint64 channel = chat_core_create_channel_auto(core.inst, "Attachment ownership"); QVERIFY(channel);
|
|
core.poll();
|
|
const QByteArray source = core.directory.filePath("recording.raw").toUtf8();
|
|
QFile file(QString::fromUtf8(source)); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write("source"), qint64(6));
|
|
file.close();
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
watching = true;
|
|
#endif
|
|
struct PcmOwner { std::atomic<int>* releases; int16_t samples[24000]; };
|
|
auto* owner = static_cast<PcmOwner*>(u_calloc(1, sizeof(PcmOwner))); QVERIFY(owner); owner->releases = &releases;
|
|
auto* request = static_cast<attachment_send_req*>(u_calloc(1, sizeof(attachment_send_req))); QVERIFY(request);
|
|
request->pcm = owner->samples; request->pcm_count = 24000; request->pcm_owner = owner;
|
|
request->pcm_release = [](void* arg) { auto* pcm = static_cast<PcmOwner*>(arg); ++*pcm->releases; u_free(pcm); };
|
|
request->info.kind = ATTACHMENT_VOICE; request->request_id = 77;
|
|
qstrncpy(request->target, qPrintable(QString::number(channel)), sizeof(request->target));
|
|
qstrncpy(request->source, source.constData(), sizeof(request->source)); request->temporary_source = temporary;
|
|
if (scenario == "unavailable") gui_bridge_set_inst(nullptr);
|
|
if (scenario == "stopped") uasync_mark_stopped(core.ua);
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
failAllocation = scenario == "queue-oom" ? 1 : 0;
|
|
#endif
|
|
const bool accepted = scenario == "executed" || scenario == "cancelled";
|
|
const int rc = gui_bridge_submit_attachment(request); QCOMPARE(rc, accepted ? 0 : -1);
|
|
if (rc) attachment_send_discard(request);
|
|
if (scenario == "executed") {
|
|
QTRY_VERIFY_WITH_TIMEOUT(([&] { core.poll(); return submissions.contains(77); })(), 5000);
|
|
QVERIFY(submissions.value(77));
|
|
DbManager db; db.setDb(chat_core_get_db(core.inst)); db.setMyNodeId(core.inst->node_id);
|
|
const auto messages = db.getMessages(QString::number(channel), 1);
|
|
QCOMPARE(messages.size(), 1); QCOMPARE(messages.first().contentType, QString("audio/opus"));
|
|
} else {
|
|
gui_bridge_set_inst(nullptr); QCoreApplication::processEvents();
|
|
QCOMPARE(submissions.contains(77), scenario == "cancelled");
|
|
if (submissions.contains(77)) QVERIFY(!submissions.value(77));
|
|
}
|
|
QCOMPARE(releases.load(), 1); QCOMPARE(QFile::exists(QString::fromUtf8(source)), !temporary);
|
|
}
|
|
void discardUnsubmittedCompressor() {
|
|
const auto baseline = u_get_allocated_count();
|
|
auto* request = static_cast<attachment_send_req*>(u_calloc(1, sizeof(attachment_send_req))); QVERIFY(request);
|
|
auto discard = qScopeGuard([&] { attachment_send_discard(request); });
|
|
request->compressor = audio_compressor_create(); QVERIFY(request->compressor);
|
|
const audio_compressor_config_t settings{}; QCOMPARE(audio_compressor_configure(request->compressor, &settings), 0);
|
|
const std::vector<int16_t> samples(24000, 0);
|
|
QCOMPARE(audio_compressor_push(request->compressor, samples.data(), samples.size()), 0);
|
|
attachment_send_discard(request); request = nullptr; QCOMPARE(u_get_allocated_count(), baseline);
|
|
}
|
|
void orderedWindowShutdownPersistsGeometry() {
|
|
QTemporaryDir directory; QVERIFY(directory.isValid());
|
|
const QString path = directory.filePath("shutdown.cfg"), log = directory.filePath("shutdown.log");
|
|
QFile config(path); QVERIFY(config.open(QIODevice::WriteOnly));
|
|
const QByteArray text = "[global]\nauto_sockets=no\n"
|
|
"my_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n"
|
|
"my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n"
|
|
"[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n"
|
|
"member_files_enabled=0\nlocal_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n";
|
|
QCOMPARE(config.write(text), text.size()); config.close();
|
|
auto logger = qScopeGuard([] { debug_disable_file_output(); });
|
|
DbManager db; MainWindow window(nullptr, &db, path, directory.path(), log);
|
|
auto* node = window.utunNode();
|
|
if (!node || !node->isRunning()) {
|
|
directory.setAutoRemove(false);
|
|
DEBUG_ERROR(DEBUG_CATEGORY_SYS, "Shutdown fixture startup failed; diagnostics: %s", qPrintable(log));
|
|
}
|
|
QVERIFY(node && node->isRunning()); QTRY_VERIFY(db.isOpen());
|
|
window.resize(1017, 713); const QRect geometry = window.normalGeometry(); QVERIFY(!geometry.isEmpty());
|
|
const QString expected = QStringLiteral("%1,%2,%3,%4").arg(geometry.width()).arg(geometry.height()).arg(geometry.x()).arg(geometry.y());
|
|
QSignalSpy stopping(node, &UtunNode::stopping), stopped(node, &UtunNode::stopped);
|
|
window.requestShutdown(); QTRY_COMPARE_WITH_TIMEOUT(stopped.size(), 1, 5000);
|
|
QCOMPARE(stopping.size(), 1); QVERIFY(!db.isOpen());
|
|
sqlite3* saved = nullptr;
|
|
QCOMPARE(sqlite3_open_v2(qPrintable(directory.filePath("chats.db")), &saved, SQLITE_OPEN_READONLY, nullptr), SQLITE_OK);
|
|
auto close = qScopeGuard([&] { sqlite3_close(saved); });
|
|
DbManager snapshot; snapshot.setDb(saved);
|
|
QCOMPARE(snapshot.getUiState("window_geometry"), expected);
|
|
QCOMPARE(snapshot.getUiState("window_maximized"), QString("0"));
|
|
QVERIFY(!snapshot.getUiState("window_splitter").isEmpty()); snapshot.setDb(nullptr);
|
|
}
|
|
void dmAcknowledgment_data() {
|
|
QTest::addColumn<QString>("scenario");
|
|
for (const auto& name : {"executed", "stale-generation", "cancelled", "unavailable"})
|
|
QTest::newRow(name) << QString(name);
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
QTest::newRow("queue-oom") << QString("queue-oom");
|
|
#endif
|
|
}
|
|
void dmAcknowledgment() {
|
|
QFETCH(QString, scenario); DesktopCore core; QVERIFY(core.start());
|
|
SC_MYKEYS peer{}; uint8_t ed[32];
|
|
QCOMPARE(sc_generate_keypair(&peer), SC_OK); QCOMPARE(sc_derive_ed25519_pubkey(peer.private_key, ed), SC_OK);
|
|
QCOMPARE(dm_start(core.inst, sc_derive_node_id_from_pubkey(peer.public_key), peer.public_key, ed, "Peer", nullptr), 0);
|
|
DbManager db; db.setDb(chat_core_get_db(core.inst)); const auto conversations = db.getDmConversations();
|
|
QCOMPARE(conversations.size(), 1); core.poll();
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
watching = true;
|
|
#endif
|
|
auto* request = static_cast<dm_send_req*>(u_calloc(1, sizeof(dm_send_req) + 5)); QVERIFY(request);
|
|
qstrncpy(request->conv_id, qPrintable(conversations.first().convId), sizeof(request->conv_id));
|
|
request->generation = conversations.first().generation + (scenario == "stale-generation");
|
|
request->request_id = 88; request->data_len = 5; request->data = reinterpret_cast<uint8_t*>(request + 1);
|
|
memcpy(request->data, "hello", 5);
|
|
if (scenario == "unavailable") gui_bridge_set_inst(nullptr);
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
failAllocation = scenario == "queue-oom" ? 1 : 0;
|
|
#endif
|
|
const bool accepted = scenario != "unavailable" && scenario != "queue-oom";
|
|
const int rc = gui_bridge_submit_dm_message(request); QCOMPARE(rc, accepted ? 0 : -1);
|
|
if (rc) u_free(request);
|
|
if (scenario == "cancelled") gui_bridge_set_inst(nullptr);
|
|
else if (accepted) core.poll();
|
|
if (accepted) {
|
|
QTRY_VERIFY(submissions.contains(88)); QCOMPARE(submissions.value(88), scenario == "executed");
|
|
} else { QCoreApplication::processEvents(); QVERIFY(!submissions.contains(88)); }
|
|
QCOMPARE(db.getDmConversations().first().lastOutSeq, qint64(scenario == "executed" ? 1 : 0));
|
|
}
|
|
void dmComposerKeepsRejectedDraft() {
|
|
InputBar input(nullptr, nullptr); input.setDmMode(true); input.setRecordingContext(".", "dm:7", 1);
|
|
auto* editor = input.findChild<QTextEdit*>(); QVERIFY(editor); editor->setPlainText("keep this draft");
|
|
quint64 request = 0;
|
|
connect(&input, &InputBar::sendMessage, &input, [&](const QString&, const QByteArray&, quint64 id) {
|
|
request = id; input.submissionResult(id, false);
|
|
});
|
|
QVERIFY(QMetaObject::invokeMethod(&input, "onSendClicked", Qt::DirectConnection));
|
|
QVERIFY(request); QCOMPARE(editor->toPlainText(), QString("keep this draft"));
|
|
input.submissionResult(0, false); QCOMPARE(editor->toPlainText(), QString("keep this draft"));
|
|
}
|
|
void sqliteReadsAreAtomicSnapshots() {
|
|
sqlite3* handle = nullptr; QCOMPARE(sqlite3_open(":memory:", &handle), SQLITE_OK);
|
|
auto close = qScopeGuard([&] { sqlite3_close(handle); });
|
|
QCOMPARE(sqlite3_exec(handle, "CREATE TABLE accounts(node_id,display_name,avatar_color,avatar_letter,is_contact);"
|
|
"INSERT INTO accounts VALUES(1,'Alice','red','A',1),(2,'Bob','blue','B',1);"
|
|
"CREATE TABLE ui_state(key,value);INSERT INTO ui_state VALUES('draft','original');"
|
|
"CREATE TABLE attachment_keys(channel,message_id,ready,downloaded,decrypting,error,plain_path,info,content_type,key);"
|
|
"CREATE TABLE attachment_key_requests(channel,message_id,incoming,state);"
|
|
"INSERT INTO attachment_keys VALUES('42',zeroblob(32),1,1,0,0,'original.opus',X'010203','audio/opus',zeroblob(32));",
|
|
nullptr, nullptr, nullptr), SQLITE_OK);
|
|
DbManager db; db.setDb(handle);
|
|
QCOMPARE(db.getAccounts().size(), 2); QCOMPARE(db.getUiState("draft"), QString("original"));
|
|
QVERIFY(!db.prepareOrNull("DELETE FROM accounts")); QCOMPARE(db.getAccounts().size(), 2);
|
|
const auto snapshot = db.getAttachment("42", QByteArray(32, '\0'));
|
|
QVERIFY(snapshot.found && snapshot.ready && snapshot.downloaded && snapshot.hasKey);
|
|
QCOMPARE(snapshot.info, QByteArray::fromHex("010203")); QCOMPARE(snapshot.requestState, -1);
|
|
QCOMPARE(sqlite3_exec(handle, "UPDATE attachment_keys SET plain_path='new.opus',info=X'04'", nullptr, nullptr, nullptr), SQLITE_OK);
|
|
QCOMPARE(snapshot.path, QString("original.opus")); QCOMPARE(snapshot.info, QByteArray::fromHex("010203"));
|
|
QCOMPARE(db.getAttachment("42", QByteArray(32, '\0')).path, QString("new.opus"));
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
sqlMatch = "FROM accounts"; sqlStage = 1; sqlCalls = 2;
|
|
QVERIFY(db.getAccounts().isEmpty()); QCOMPARE(sqlStage, 0);
|
|
sqlMatch = "FROM ui_state"; sqlStage = 2; sqlCalls = 1;
|
|
QVERIFY(db.getUiState("draft").isEmpty()); QCOMPARE(sqlStage, 0);
|
|
sqlMatch = "FROM accounts"; sqlStage = 3; sqlCalls = 1;
|
|
QVERIFY(db.getAccounts().isEmpty()); QCOMPARE(sqlStage, 0);
|
|
sqlMatch = "FROM attachment_keys"; sqlStage = 2; sqlCalls = 1;
|
|
QVERIFY(!db.getAttachment("42", QByteArray(32, '\0')).found); QCOMPARE(sqlStage, 0);
|
|
#endif
|
|
QCOMPARE(db.getAccounts().size(), 2); QCOMPARE(db.getUiState("draft"), QString("original"));
|
|
QVERIFY(!sqlite3_next_stmt(handle, nullptr)); db.setDb(nullptr);
|
|
}
|
|
void mediaGesture_data() {
|
|
QTest::addColumn<QString>("gesture");
|
|
for (const auto& name : {"release-only", "drag", "blank", "click"}) QTest::newRow(name) << QString(name);
|
|
}
|
|
void mediaGesture() {
|
|
QFETCH(QString, gesture); QListView view; MessageDelegate delegate; QStandardItemModel model;
|
|
auto* item = new QStandardItem; item->setData("video/mp4", MsgContentTypeRole);
|
|
item->setData("42", MsgChannelIdRole); item->setData(1, MsgIdRole); model.appendRow(item);
|
|
QStyleOptionViewItem option; option.widget = &view; option.rect = QRect(0, 0, 500, 200);
|
|
int downloads = 0; delegate.setDownloadCallback([&](const QModelIndex&) { ++downloads; return true; });
|
|
const QPointF begin = gesture == "blank" ? QPointF(450, 40) : QPointF(100, 40);
|
|
const QPointF end = gesture == "drag" ? QPointF(220, 40) : begin;
|
|
QMouseEvent press(QEvent::MouseButtonPress, begin, begin, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
|
|
QMouseEvent release(QEvent::MouseButtonRelease, end, end, Qt::LeftButton, Qt::NoButton, Qt::NoModifier);
|
|
if (gesture != "release-only") delegate.editorEvent(&press, &model, option, model.index(0, 0));
|
|
delegate.editorEvent(&release, &model, option, model.index(0, 0));
|
|
QCOMPARE(downloads, gesture == "click" ? 1 : 0);
|
|
}
|
|
void voiceTimerSurvivesViewDestructionAndStopsWithoutPaint() {
|
|
auto* sound = SoundManager::instance(); QVERIFY(sound->init());
|
|
auto shutdown = qScopeGuard([&] { MessageDelegate::stopAllAutoPlay(); sound->shutdown(); });
|
|
auto pcm = std::make_shared<std::vector<int16_t>>(48000, 0);
|
|
const auto id = sound->playRawPcm(pcm->data(), int(pcm->size()), 48000, 1, pcm); QVERIFY(id);
|
|
QStandardItemModel model; auto* item = new QStandardItem;
|
|
item->setData("audio/opus", MsgContentTypeRole); item->setData("voice-test.opus", MsgVoiceFileRole);
|
|
item->setData("1.0s", MsgVoiceDurationRole); item->setData(2, MsgFileDownloadStateRole); model.appendRow(item);
|
|
MessageDelegate delegate; auto* view = new QListView;
|
|
QStyleOptionViewItem option; option.widget = view; option.rect = QRect(0, 0, 500, 200);
|
|
QImage image(500, 200, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent);
|
|
{ QPainter painter(&image); delegate.paint(&painter, option, model.index(0, 0)); }
|
|
MessageDelegate::startAutoPlay("voice-test.opus", 1.0f, id);
|
|
auto* timer = qApp->findChild<QTimer*>("messageVoiceTimer"); QVERIFY(timer && timer->isActive());
|
|
QTRY_VERIFY_WITH_TIMEOUT(sound->pcmCursor(id) > 0, 2000);
|
|
// Ни paint, ни видимость view не должны требоваться для завершения прогресса.
|
|
sound->stopPcm(id); QTRY_VERIFY_WITH_TIMEOUT(!timer->isActive(), 3000);
|
|
MessageDelegate::startAutoPlay("voice-test.opus", 1.0f, 999);
|
|
QVERIFY(timer->isActive()); delete view; QVERIFY(!timer->isActive()); QTest::qWait(30);
|
|
}
|
|
void lifecycle_data() {
|
|
QTest::addColumn<int>("action"); QTest::addColumn<QString>("scenario"); QTest::addColumn<bool>("window");
|
|
QStringList scenarios{"unavailable", "stopped", "cancelled", "executed"};
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
scenarios << "request-oom" << "queue-oom";
|
|
#endif
|
|
for (int action = GUI_RADIO_CALL_START; action <= GUI_RADIO_CALL_CONTINUE; ++action)
|
|
for (const auto& scenario : scenarios)
|
|
QTest::newRow(qPrintable(QString("bridge-%1-%2").arg(action).arg(scenario))) << action << scenario << false;
|
|
for (const auto& scenario : scenarios)
|
|
QTest::newRow(qPrintable("window-" + scenario)) << int(GUI_RADIO_CALL_START) << scenario << true;
|
|
QTest::newRow("window-destroyed-before-execution") << int(GUI_RADIO_CALL_START) << QString("destroyed") << true;
|
|
}
|
|
void lifecycle() {
|
|
QFETCH(int, action); QFETCH(QString, scenario); QFETCH(bool, window);
|
|
DesktopCore core; QVERIFY(core.start());
|
|
std::unique_ptr<MainWindow> form;
|
|
if (window) {
|
|
form = std::make_unique<MainWindow>(nullptr, nullptr, QString()); // UtunNode остаётся владельцу fixture.
|
|
core.poll(); QCoreApplication::processEvents(); // Отделить стартовые настройки media от проверяемой команды.
|
|
}
|
|
if (scenario == "unavailable") gui_bridge_set_inst(nullptr);
|
|
if (scenario == "stopped") uasync_mark_stopped(core.ua);
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
watching = true; failAllocation = scenario == "request-oom" ? 1 : scenario == "queue-oom" ? 2 : 0;
|
|
#endif
|
|
const bool accepted = scenario == "executed" || scenario == "cancelled" || scenario == "destroyed";
|
|
const uint64_t group = action == GUI_RADIO_CALL_START ? 7654321 : 0, peer = action == GUI_RADIO_CALL_START ? 42 : 0;
|
|
if (form) form->onRadioCallRequested(QString::number(group), peer);
|
|
else QCOMPARE(gui_bridge_radio_call(static_cast<gui_radio_call_action>(action), 73, group, peer), accepted ? 0 : -1);
|
|
QCOMPARE(pending(core.ua), accepted ? 1u : 0u);
|
|
if (scenario == "destroyed") form.reset();
|
|
const bool executes = scenario == "executed" || scenario == "destroyed";
|
|
if (executes) core.poll();
|
|
else gui_bridge_set_inst(nullptr);
|
|
QCOMPARE(pending(core.ua), 0u); QVERIFY(bindingValid);
|
|
QCOMPARE(core.results, executes && action == GUI_RADIO_CALL_START ? 1u : 0u); QVERIFY(core.resultValid);
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
QCOMPARE(failAllocation.load(), 0); QCOMPARE(observed.size(), executes ? 1 : 0);
|
|
if (executes) {
|
|
static const int nativeActions[]{RADIO_CALL_START, RADIO_CALL_READY, RADIO_CALL_ACCEPT, RADIO_CALL_DECLINE,
|
|
RADIO_CALL_CANCEL, RADIO_CALL_FAIL, RADIO_CALL_REJECT_BUSY, RADIO_CALL_CONTINUE};
|
|
const auto& request = observed.front(); QCOMPARE(request.inst, core.inst);
|
|
QCOMPARE(request.action, nativeActions[action]); QCOMPARE(request.group, group); QCOMPARE(request.peer, peer);
|
|
if (window) QVERIFY(request.id);
|
|
else QCOMPARE(request.id, uint64_t(73));
|
|
}
|
|
#endif
|
|
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "Radio command verified: window=%d action=%d scenario=%s results=%u",
|
|
window, action, qPrintable(scenario), core.results);
|
|
}
|
|
void invalidArguments() {
|
|
DesktopCore core; QVERIFY(core.start()); const auto baseline = u_get_allocated_count();
|
|
QCOMPARE(gui_bridge_radio_call(static_cast<gui_radio_call_action>(99), 1, 0, 0), -1);
|
|
QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 0, 1, 1), -1);
|
|
QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 1, 0, 1), -1);
|
|
QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 1, 1, 0), -1);
|
|
QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 0, 0, 0), -1);
|
|
QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 1, 1, 0), -1);
|
|
QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 1, 0, 1), -1);
|
|
QCOMPARE(pending(core.ua), 0u); QCOMPARE(u_get_allocated_count(), baseline);
|
|
}
|
|
void concurrentRebind() {
|
|
DesktopCore first, second; QVERIFY(first.start()); gui_bridge_set_inst(nullptr); QVERIFY(second.start());
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
watching = true;
|
|
#endif
|
|
std::atomic<unsigned> turn{0}, submitted{0};
|
|
std::thread producer([&] {
|
|
for (unsigned id = 1; id <= 96; ++id) {
|
|
while (turn < id) std::this_thread::yield();
|
|
gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, id, 0, 0); submitted = id;
|
|
}
|
|
});
|
|
bool controlsAccepted = true;
|
|
for (unsigned i = 1; i <= 96; ++i) {
|
|
turn = i;
|
|
gui_bridge_set_inst(nullptr); auto& core = i % 2 ? first : second; core.attach();
|
|
while (submitted < i) std::this_thread::yield();
|
|
first.poll(); second.poll();
|
|
// Контроль исполняется у каждого владельца даже при отклонении/отмене конкурирующей команды.
|
|
controlsAccepted &= gui_bridge_radio_call(GUI_RADIO_CALL_CANCEL, 1000 + i, 0, 0) == 0; core.poll();
|
|
}
|
|
producer.join(); gui_bridge_set_inst(nullptr); first.attach();
|
|
QCOMPARE(gui_bridge_radio_call(GUI_RADIO_CALL_START, 2000, 7654321, 42), 0); first.poll();
|
|
QVERIFY(controlsAccepted); QVERIFY(bindingValid); QCOMPARE(pending(first.ua), 0u); QCOMPARE(pending(second.ua), 0u);
|
|
QCOMPARE(first.results, 1u); QCOMPARE(second.results, 0u); QVERIFY(first.resultValid);
|
|
#ifdef UTUN_TEST_DESKTOP_HOOKS
|
|
size_t firstCount = 0, secondCount = 0;
|
|
for (const auto& request : observed) { firstCount += request.inst == first.inst; secondCount += request.inst == second.inst; }
|
|
QVERIFY(firstCount >= 49); QVERIFY(secondCount >= 48);
|
|
QCOMPARE(observed.back().inst, first.inst); QCOMPARE(observed.back().id, uint64_t(2000));
|
|
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "Radio binding verified: first_executed=%zu second_executed=%zu binding_valid=%d",
|
|
firstCount, secondCount, bindingValid);
|
|
#endif
|
|
}
|
|
};
|
|
|
|
QTEST_MAIN(DesktopCommandsTest)
|
|
#include "test_desktop_commands.moc"
|