#include "../src/animtimer.h" #include "../src/chatview.h" #include "../src/emoji.h" #include "../src/messagedelegate.h" #include "../src/emojipanel.h" #include "../src/lottieicon.h" #include "../../../lib/debug_config.h" #include #include #include #include #include #include #include #include static QStringList playbackLog; static QStringList errorLog; static void captureLog(int level, const char *, const char *message) { playbackLog.append(QString::fromUtf8(message)); if (level == DEBUG_LEVEL_ERROR) errorLog.append(QString::fromUtf8(message)); } static QByteArray gzipData(QByteArray json) { QByteArray gzip(int(compressBound(json.size()) + 32), '\0'); z_stream stream = {}; if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) return {}; stream.next_in = reinterpret_cast(json.data()); stream.avail_in = json.size(); stream.next_out = reinterpret_cast(gzip.data()); stream.avail_out = gzip.size(); int result = deflate(&stream, Z_FINISH); gzip.resize(stream.total_out); deflateEnd(&stream); return result == Z_STREAM_END ? gzip : QByteArray(); } // Два коротких цикла разной длины выявляют остановку посреди кадра и ожидание общего кратного. static QByteArray animationData(int frames) { return gzipData(QString(R"({"v":"5.7.4","fr":30,"ip":0,"op":%1,"w":32,"h":32,"layers":[]})").arg(frames).toUtf8()); } class EmojiPlaybackTest : public QObject { Q_OBJECT std::unique_ptr shortIcon, longIcon; private slots: void init() { debug_config_init(); debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); debug_set_log_hook(captureLog); playbackLog.clear(); errorLog.clear(); shortIcon = std::make_unique(animationData(7), "short"); longIcon = std::make_unique(animationData(11), "long"); QVERIFY(shortIcon->isValid()); QVERIFY(longIcon->isValid()); AnimTimer::instance()->registerIcon("short", shortIcon.get()); AnimTimer::instance()->registerIcon("long", longIcon.get()); } void cleanup() { AnimTimer::shutdown(); debug_set_log_hook(nullptr); shortIcon.reset(); longIcon.reset(); } void rejectsInvalidAnimations_data() { QTest::addColumn("data"); const auto valid = animationData(7); QTest::newRow("empty") << QByteArray(); QTest::newRow("not-gzip") << QByteArray("not an animation"); QTest::newRow("short-header") << valid.left(4); QTest::newRow("missing-trailer") << valid.left(valid.size() - 4); auto corrupt = valid; corrupt[corrupt.size() - 1] = char(corrupt.back() ^ 1); QTest::newRow("invalid-size-checksum") << corrupt; QTest::newRow("trailing-data") << valid + QByteArray("trailing"); QTest::newRow("invalid-json") << gzipData("{broken"); QTest::newRow("zero-frames") << animationData(0); QTest::newRow("json-budget") << gzipData(QByteArray(LottieIcon::MaxJsonBytes + 1, ' ')); } void rejectsInvalidAnimations() { QFETCH(QByteArray, data); LottieIcon invalid(data, QTest::currentDataTag()); QVERIFY(!invalid.isValid()); QVERIFY(!errorLog.filter("LottieIcon:").isEmpty()); invalid.advanceFrame(); QCOMPARE(invalid.currentFrame(), 0); QVERIFY(invalid.renderFrame(0, QSize(32, 32)).isNull()); AnimTimer::instance()->registerIcon("invalid", &invalid); QVERIFY(AnimTimer::instance()->icon("invalid") == nullptr); AnimTimer::instance()->registerIcon("short", nullptr); QCOMPARE(AnimTimer::instance()->icon("short"), shortIcon.get()); QVERIFY(!playbackLog.filter("LottieIcon:").isEmpty()); } void bundledAnimationsRemainValid() { const QDir directory(QFINDTESTDATA("../resources/animations")); const auto files = directory.entryList({"*.tgs"}, QDir::Files); QVERIFY(!files.isEmpty()); for (const QString& name : files) { QFile file(directory.filePath(name)); QVERIFY(file.open(QIODevice::ReadOnly)); LottieIcon icon(file.readAll(), "bundled-" + name); QVERIFY2(icon.isValid(), qPrintable(name)); QVERIFY(!icon.renderFrame(0, QSize(32, 32)).isNull()); } } void rendersOwnedImagesAndWrapsFrameNumbers() { QFile file(QFINDTESTDATA("../resources/animations/robot.tgs")); QVERIFY(file.open(QIODevice::ReadOnly)); LottieIcon icon(file.readAll(), "render-robot"); QVERIFY(icon.isValid()); QImage first = icon.renderFrame(0, QSize(48, 48)); QVERIFY(!first.isNull()); QCOMPARE(first.format(), QImage::Format_ARGB32_Premultiplied); QCOMPARE(icon.renderFrame(-1, first.size()), icon.renderFrame(icon.frameCount() - 1, first.size())); QCOMPARE(icon.renderFrame(icon.frameCount(), first.size()), first); const QImage saved = first; first.fill(Qt::red); QCOMPARE(icon.renderFrame(0, saved.size()), saved); QVERIFY(icon.renderFrame(0, QSize(0, 32)).isNull()); } void messagesFinishEachCycle() { auto *timer = AnimTimer::instance(); QSignalSpy ticks(timer, &AnimTimer::ticked); timer->play(); QTest::qWait(1850); QVERIFY(playbackLog.filter("AnimTimer: playback finished").isEmpty()); int count = ticks.count(); QTest::qWait(100); QVERIFY(ticks.count() > count); QTRY_COMPARE_WITH_TIMEOUT(playbackLog.filter("AnimTimer: playback finished").size(), 1, 1000); QCOMPARE(shortIcon->currentFrame(), 0); QCOMPARE(longIcon->currentFrame(), 0); count = ticks.count(); QTest::qWait(150); QCOMPARE(ticks.count(), count); } void anotherHoverRestartsDeadline() { auto *timer = AnimTimer::instance(); QSignalSpy ticks(timer, &AnimTimer::ticked); timer->play(); QTest::qWait(1400); timer->play(); QTest::qWait(1000); QVERIFY(playbackLog.filter("AnimTimer: playback finished").isEmpty()); int count = ticks.count(); QTest::qWait(100); QVERIFY(ticks.count() > count); QTRY_COMPARE_WITH_TIMEOUT(playbackLog.filter("AnimTimer: playback finished").size(), 1, 1500); QCOMPARE(shortIcon->currentFrame(), 0); QCOMPARE(longIcon->currentFrame(), 0); } void pickerFinishesWithOrWithoutHover() { AnimTimer::instance()->registerIcon(":/animations/robot.tgs", shortIcon.get()); EmojiPanel panel; panel.resize(320, 240); panel.findChild()->setCurrentIndex(3); panel.show(); QPushButton *button = nullptr; for (auto *candidate : panel.findChildren()) { if (candidate->toolTip() == ":robot:" && candidate->hasMouseTracking()) button = candidate; } QVERIFY(button); QEvent leave(QEvent::Leave); QApplication::sendEvent(button, &leave); #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QEnterEvent enter(QPointF(1, 1), QPointF(1, 1), QPointF(1, 1)); #else QEvent enter(QEvent::Enter); #endif QApplication::sendEvent(button, &enter); QTest::qWait(1850); QVERIFY(playbackLog.filter("EmojiButton: playback finished").isEmpty()); QTRY_COMPARE_WITH_TIMEOUT(playbackLog.filter("EmojiButton: playback finished").size(), 1, 1000); QTest::qWait(150); QCOMPARE(playbackLog.filter("EmojiButton: hover trigger").size(), 1); QApplication::sendEvent(button, &leave); QApplication::sendEvent(button, &enter); QTest::qWait(100); QApplication::sendEvent(button, &leave); QTest::qWait(1750); QCOMPARE(playbackLog.filter("EmojiButton: playback finished").size(), 1); QTRY_COMPARE_WITH_TIMEOUT(playbackLog.filter("EmojiButton: playback finished").size(), 2, 1000); } void visibleMessagesTriggerOnce() { builtinEmojiSet(); QStandardItemModel model; for (int row = 0; row < 6; ++row) { auto *item = new QStandardItem("message"); item->setSizeHint(QSize(200, 60)); item->setData(row + 1, MsgIdRole); item->setData("test-channel", MsgChannelIdRole); item->setData(row == 0 || row == 5 ? QString::fromUtf8("🤖") : QString("text"), MsgTextRole); model.appendRow(item); } ChatView view; view.setModel(&model); view.resize(240, 140); QSignalSpy triggers(&view, &ChatView::animationTriggered); connect(&view, &ChatView::animationTriggered, AnimTimer::instance(), &AnimTimer::play); view.show(); QTRY_COMPARE(triggers.count(), 1); view.viewport()->update(); QTest::qWait(100); QCOMPARE(triggers.count(), 1); view.scrollToBottom(); QTRY_COMPARE(triggers.count(), 2); view.scrollToTop(); QTRY_COMPARE(triggers.count(), 3); view.scrollToBottom(); QTRY_COMPARE(triggers.count(), 4); auto *message = new QStandardItem("new message"); message->setSizeHint(QSize(200, 60)); message->setData(7, MsgIdRole); message->setData("test-channel", MsgChannelIdRole); message->setData(QString::fromUtf8("🤖"), MsgTextRole); model.appendRow(message); view.scrollToBottom(); QTRY_COMPARE(triggers.count(), 5); view.viewport()->update(); QTest::qWait(100); QCOMPARE(triggers.count(), 5); } void hoverTriggersOnEntry() { builtinEmojiSet(); QStandardItemModel model; auto *message = new QStandardItem("message"); message->setSizeHint(QSize(200, 60)); message->setData(1, MsgIdRole); message->setData(QString::fromUtf8("🤖"), MsgTextRole); model.appendRow(message); ChatView view; view.setModel(&model); view.resize(240, 140); view.show(); QTest::qWait(50); QSignalSpy triggers(&view, &ChatView::animationTriggered); const QPointF point = view.visualRect(model.index(0, 0)).center(); QMouseEvent move(QEvent::MouseMove, point, point, Qt::NoButton, Qt::NoButton, Qt::NoModifier); QApplication::sendEvent(view.viewport(), &move); QCOMPARE(triggers.count(), 1); QApplication::sendEvent(view.viewport(), &move); QCOMPARE(triggers.count(), 1); QEvent leave(QEvent::Leave); QApplication::sendEvent(view.viewport(), &leave); QCOMPARE(triggers.count(), 1); QApplication::sendEvent(view.viewport(), &move); QCOMPARE(triggers.count(), 2); } void inviteClickDoesNotActivateOnDrag() { QStandardItemModel model; auto* message = new QStandardItem; message->setSizeHint(QSize(200, 60)); message->setData(QStringLiteral("utun://abc+/=="), MsgTextRole); model.appendRow(message); ChatView view; view.setModel(&model); view.resize(240, 140); view.setLinkHitTest([](const QModelIndex& index, const QPoint&) { return index.data(MsgTextRole).toString(); }); view.show(); QTest::qWait(30); const QPoint point = view.visualRect(model.index(0, 0)).center(); QSignalSpy links(&view, &ChatView::linkActivated); QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, point); QCOMPARE(links.count(), 1); QCOMPARE(links.first().first().toString(), QStringLiteral("utun://abc+/==")); QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, point); const QPointF dragged = point + QPoint(15, 0); QMouseEvent move(QEvent::MouseMove, dragged, dragged, Qt::NoButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(view.viewport(), &move); QTest::mouseRelease(view.viewport(), Qt::LeftButton, Qt::NoModifier, dragged.toPoint()); QCOMPARE(links.count(), 1); QVERIFY(view.hasSelection()); } }; QTEST_MAIN(EmojiPlaybackTest) #include "test_emoji_playback.moc"