From bfc280310dc67723e84c90252f59601d516419cd Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 12:07:36 +0100 Subject: [PATCH] Cover avatar crop input bounds and modal owner lifetime --- tools/chatgui/CMakeLists.txt | 5 + tools/chatgui/tests/test_avatar_crop.cpp | 115 +++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 tools/chatgui/tests/test_avatar_crop.cpp diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 103ccb3e..39674883 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -569,6 +569,11 @@ target_link_libraries(test_member_files_browser PRIVATE ${QT_LIBS} ${QT_TEST_LIB add_test(NAME test_member_files_browser COMMAND test_member_files_browser) set_tests_properties(test_member_files_browser PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30) +add_executable(test_avatar_crop tests/test_avatar_crop.cpp src/avatars/avatarcrop.cpp) +target_link_libraries(test_avatar_crop PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun pthread ${CMAKE_DL_LIBS}) +add_test(NAME test_avatar_crop COMMAND test_avatar_crop) +set_tests_properties(test_avatar_crop PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 20) + add_executable(test_avatars tests/test_avatars.cpp src/avatars/avatarcache.cpp src/avatars/avatarmanager.cpp src/avatars/avatarcrop.cpp src/avatars/avatargallery.cpp src/imageviewer_window.cpp db/db_manager.cpp) target_link_libraries(test_avatars PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun pthread) diff --git a/tools/chatgui/tests/test_avatar_crop.cpp b/tools/chatgui/tests/test_avatar_crop.cpp new file mode 100644 index 00000000..91ee6d4c --- /dev/null +++ b/tools/chatgui/tests/test_avatar_crop.cpp @@ -0,0 +1,115 @@ +/* Настоящий модальный редактор: ввод, минимальный квадрат и уничтожение владельца. */ +#include "../src/avatars/avatarcrop.h" +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "debug_config.h" +} + +static void wheel(QWidget* view, QPoint angle, QPoint pixels = {}) { + const QPoint center = view->rect().center(); + QWheelEvent event(center, view->mapToGlobal(center), pixels, angle, Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); + QApplication::sendEvent(view, &event); +} + +static bool edit(const QString& path, QRectF& crop, QWidget* parent, + const std::function& action, bool waitForImage = true) { + QTimer poll, deadline; poll.setInterval(5); deadline.setSingleShot(true); bool timedOut = false; + QObject::connect(&poll, &QTimer::timeout, [&] { + auto* dialog = qobject_cast(QApplication::activeModalWidget()); if (!dialog) return; + auto* view = dialog->findChild("avatarCropView"); + auto* upload = dialog->findChild("avatarCropUpload"); + if (!view || !upload || (waitForImage && !upload->isEnabled())) return; + poll.stop(); action(dialog, view, upload); + }); + QObject::connect(&deadline, &QTimer::timeout, [&] { + timedOut = true; + if (auto* dialog = qobject_cast(QApplication::activeModalWidget())) dialog->reject(); + }); + poll.start(); deadline.start(3000); + const bool result = selectAvatarCrop(path, crop, parent); + if (timedOut) QTest::qFail("crop editor did not reach the requested state", __FILE__, __LINE__); + return result; +} + +class AvatarCropTest : public QObject { + Q_OBJECT +private slots: + void initTestCase() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); } + + void invalidAcceptancePreservesOutput() { + const QRectF before(.1, .2, .3, .4); QRectF crop = before; + QVERIFY(!edit(QStringLiteral("/nonexistent/avatar.png"), crop, nullptr, + [](QDialog* dialog, QWidget*, QPushButton*) { dialog->accept(); }, false)); + QCOMPARE(crop, before); + } + + void zeroAndHorizontalWheel_data() { + QTest::addColumn("angle"); QTest::addColumn("pixels"); + QTest::newRow("zero") << QPoint() << QPoint(); + QTest::newRow("horizontal-angle") << QPoint(120, 0) << QPoint(); + QTest::newRow("horizontal-pixels") << QPoint() << QPoint(30, 0); + } + void zeroAndHorizontalWheel() { + QFETCH(QPoint, angle); QFETCH(QPoint, pixels); + QTemporaryDir directory; QVERIFY(directory.isValid()); + const QString path = directory.filePath("source.png"); QImage image(800, 400, QImage::Format_RGB32); image.fill(Qt::blue); + QVERIFY(image.save(path)); QRectF crop; + QVERIFY(edit(path, crop, nullptr, [&](QDialog*, QWidget* view, QPushButton* upload) { + wheel(view, QPoint(0, 120)); wheel(view, angle, pixels); upload->click(); + })); + QVERIFY(qAbs(crop.width() - .5 / 1.15) < 1e-8); + QVERIFY(qAbs(crop.height() - 1.0 / 1.15) < 1e-8); + } + + void sourceGeometry_data() { + QTest::addColumn("size"); QTest::addColumn("zoomSteps"); + QTest::newRow("wide") << QSize(800, 400) << 0; + QTest::newRow("portrait") << QSize(400, 800) << 0; + QTest::newRow("large-zoom") << QSize(800, 400) << 32; + QTest::newRow("tiny-zoom") << QSize(20, 2) << 32; + QTest::newRow("thin") << QSize(1, 10000) << 0; + } + void sourceGeometry() { + QFETCH(QSize, size); QFETCH(int, zoomSteps); + QTemporaryDir directory; QVERIFY(directory.isValid()); + const QString path = directory.filePath("source.png"); QImage image(size, QImage::Format_RGB32); image.fill(Qt::green); + QVERIFY(image.save(path)); QRectF crop; + QVERIFY(edit(path, crop, nullptr, [&](QDialog*, QWidget* view, QPushButton* upload) { + for (int i = 0; i < zoomSteps; i++) wheel(view, QPoint(0, 120)); + upload->click(); + })); + QVERIFY(std::isfinite(crop.x()) && std::isfinite(crop.y()) && std::isfinite(crop.width()) && std::isfinite(crop.height())); + QVERIFY(crop.isValid()); QVERIFY(crop.x() >= -1e-8 && crop.y() >= -1e-8); + QVERIFY(crop.right() <= 1 + 1e-8 && crop.bottom() <= 1 + 1e-8); + const double pixelsWide = crop.width() * size.width(), pixelsHigh = crop.height() * size.height(); + QVERIFY(qAbs(pixelsWide - pixelsHigh) < 1e-6); + QVERIFY(pixelsWide >= 1 - 1e-8); + if (zoomSteps == 32) QVERIFY(qAbs(pixelsWide - qMin(size.width(), size.height()) / qMin(16.0, double(qMin(size.width(), size.height())))) < 1e-6); + } + + void cancelPreservesOutput() { + QTemporaryDir directory; QVERIFY(directory.isValid()); + const QString path = directory.filePath("source.png"); QImage image(800, 400, QImage::Format_RGB32); image.fill(Qt::blue); + QVERIFY(image.save(path)); const QRectF before(.1, .2, .3, .4); QRectF crop = before; + QVERIFY(!edit(path, crop, nullptr, [](QDialog* dialog, QWidget*, QPushButton*) { dialog->reject(); })); + QCOMPARE(crop, before); + } + + void parentCanDisappearDuringLoad() { + QPointer parent = new QWidget; + const QRectF before(.1, .2, .3, .4); QRectF crop = before; + QVERIFY(!edit(QStringLiteral("/nonexistent/avatar.png"), crop, parent, + [&](QDialog*, QWidget*, QPushButton*) { delete parent.data(); }, false)); + QVERIFY(!parent); QCOMPARE(crop, before); + } +}; + +QTEST_MAIN(AvatarCropTest) +#include "test_avatar_crop.moc"