Files
utun2/tools/chatgui/tests/test_desktop_commands.cpp
T

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"