#include "UI/ByteCounts.h" #include "UI/ColorPaletteControls.h" #include "UI/ExportAsSheet.h" #include "UI/ColorPickerSheet+Dialog.h" #include #include #include #include #include #include #include #include #include #include #include #include // Swift's ExportAsSheet: the preview waits, encodes aside, then exports. class ExportAsSheetTests : public QObject { Q_OBJECT private slots: void initTestCase(); void theSheetShowsTheRasterAndWaitsForItsPreview(); void aChangeSupersedesThePreview(); void aSupersededEncodingNeverLands(); void theMatteComesFromTheAppsPicker(); void exportKeepsTheQualityAndCancelDoesNot(); void theSavedQualityIsReadAsSwiftReadsIt(); void anEncodingThatFailsIsShown(); void eachFormatShowsItsRowsAndWritesItsBytes(); void theSizeKeepsTheProportionsWithinTheLimits(); void aClearPNGShowsTheCheckerboard(); void thePageRoundsAsSwiftsFormatter(); }; namespace { // Clear on the left, opaque red on the right. ExportRaster halfRed(int width = 64, int height = 32) { QImage image(width, height, QImage::Format_RGBA8888_Premultiplied); image.fill(Qt::transparent); for (int y = 0; y < height; ++y) for (int x = width / 2; x < width; ++x) image.setPixel(x, y, qRgba(255, 0, 0, 255)); return {image, 144}; } struct Sheet { std::optional> answer; EditorSession session; ExportAsSheet sheet; explicit Sheet(ExportRaster raster = halfRed(), ExportFormat format = ExportFormat::jpeg) : sheet(std::move(raster), session, format, [this](std::optional chosen) { answer = std::move(chosen); }) { sheet.show(); } template Widget &find(const char *name) { Widget *found = sheet.findChild(QString::fromLatin1(name)); if (!found) throw std::runtime_error(std::string("no widget named ") + name); return *found; } bool ready() { return find("exportExport").isEnabled(); } QString note() { return find("exportUpdating").text(); } QColor previewAt(QPoint point) { return find("exportPreview").grab().toImage().pixelColor(point); } }; struct PaintCount : QObject { int count = 0; explicit PaintCount(QWidget &widget) { widget.installEventFilter(this); } bool eventFilter(QObject *, QEvent *event) override { count += event->type() == QEvent::Paint; return false; } }; QStringList labels(const QWidget &widget) { QStringList texts; for (const QLabel *label : widget.findChildren()) texts << label->text(); return texts; } } void ExportAsSheetTests::initTestCase() { QStandardPaths::setTestModeEnabled(true); QSettings().clear(); } void ExportAsSheetTests::theSheetShowsTheRasterAndWaitsForItsPreview() { Sheet shown(halfRed(1200, 800)); const QStringList texts = labels(shown.sheet); QVERIFY(texts.contains("Export As") && texts.contains("Format") && texts.contains("Size") && texts.contains("Quality")); QVERIFY(texts.contains("Background") && texts.contains("sRGB")); QCOMPARE(shown.find("exportPercent").text(), QString("85%")); QCOMPARE(shown.find("exportQuality").value(), 85); QCOMPARE(shown.find("exportPreview").size(), QSize(560, 330)); // Swift's task sleeps 200 ms before it encodes. auto &wait = shown.find("exportWait"); QVERIFY(wait.isActive() && wait.isSingleShot()); QCOMPARE(wait.interval(), 200); QCOMPARE(wait.timerType(), Qt::PreciseTimer); QVERIFY(!shown.ready()); QCOMPARE(shown.note(), QString("Updating…")); QVERIFY(shown.find("exportSpinner").isVisible()); QVERIFY(!shown.find("exportBytes").isVisible() && !shown.find("exportError").isVisible()); // The material plate behind the spinner, over the dark gray. QCOMPARE(shown.previewAt({2, 2}), QColor(31, 31, 31)); const QColor window = shown.sheet.palette().color(QPalette::Window), plate = shown.previewAt({252, 165}); QVERIFY(qAbs(plate.red() - qRound(window.red() * 0.85 + 31 * 0.15)) <= 1); QVERIFY(qAbs(plate.blue() - qRound(window.blue() * 0.85 + 31 * 0.15)) <= 1); QTRY_VERIFY(shown.ready()); const JPEGResult expected = ImageExporter::jpeg(halfRed(1200, 800), {}); QCOMPARE(shown.find("exportBytes").text(), ByteCounts::file(expected.data.size())); QVERIFY(shown.find("exportBytes").isVisible() && !shown.find("exportUpdating").isVisible()); QVERIFY(!shown.find("exportSpinner").isVisible()); // Fitted to 495 × 330 and centred: white, then red. QCOMPARE(shown.previewAt({2, 2}), QColor(31, 31, 31)); QCOMPARE(shown.previewAt({10, 165}), QColor(31, 31, 31)); QVERIFY(qAbs(shown.previewAt({100, 165}).red() - 255) < 4 && qAbs(shown.previewAt({100, 165}).blue() - 255) < 4); const QColor red = shown.previewAt({460, 165}); QVERIFY(red.red() > 240 && red.green() < 16 && red.blue() < 16); } void ExportAsSheetTests::aChangeSupersedesThePreview() { Sheet shown; QTRY_VERIFY(shown.ready()); auto &quality = shown.find("exportQuality"); // Each change waits anew; the stale preview stays beneath. quality.setValue(10); QCOMPARE(shown.find("exportPercent").text(), QString("10%")); QVERIFY(!shown.ready()); QCOMPARE(shown.note(), QString("Updating…")); QVERIFY(shown.find("exportWait").isActive()); QVERIFY(shown.find("exportSpinner").isVisible()); quality.setValue(100); QTRY_VERIFY(shown.ready()); QCOMPARE(shown.find("exportBytes").text(), ByteCounts::file(ImageExporter::jpeg(halfRed(), {.quality = 1}).data.size())); shown.find("exportExport").click(); QCOMPARE(shown.answer.value().value().data, ImageExporter::jpeg(halfRed(), {.quality = 1}).data); QCOMPARE(shown.answer.value().value().format, ExportFormat::jpeg); } // Slow to encode: the change comes while it runs. void ExportAsSheetTests::aSupersededEncodingNeverLands() { QImage noisy(6000, 4000, QImage::Format_RGBA8888_Premultiplied); quint32 seed = 1; for (int y = 0; y < noisy.height(); ++y) { auto *row = reinterpret_cast(noisy.scanLine(y)); for (int x = 0; x < noisy.width(); ++x) row[x] = (seed = seed * 1664525u + 1013904223u) | 0xff000000u; } Sheet shown({noisy, 72}); QTRY_VERIFY(!shown.find("exportWait").isActive()); shown.find("exportQuality").setValue(50); // Only the newest request's preview may ever show. bool stale = false; while (!shown.ready()) { stale = stale || shown.previewAt({100, 165}) != QColor(31, 31, 31); QCoreApplication::processEvents(QEventLoop::AllEvents, 10); } QVERIFY(!stale); QCOMPARE(shown.find("exportPercent").text(), QString("50%")); } void ExportAsSheetTests::theMatteComesFromTheAppsPicker() { QSettings().remove(ExportAsSheet::qualityKey); Sheet shown; QTRY_VERIFY(shown.ready()); auto &matte = shown.find("exportBackground"); QVERIFY(matte.accessibleName() == QString("Export Background") && matte.toolTip() == QString("Color that fills transparent areas")); matte.click(); QCOMPARE(shown.session.colorPicker().value().target.title(), QString("Color Picker (Export Background)")); QVERIFY(shown.session.colorPicker().value().original == PaletteColor::white()); // The same colour asks for nothing. shown.session.closeColorPicker(true); QVERIFY(shown.ready() && !shown.find("exportWait").isActive()); // The working colour reaches the preview; Cancel takes it back. matte.click(); shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); QVERIFY(!shown.ready()); QCOMPARE(matte.grab().toImage().pixelColor(17, 9), QColor(0, 0, 255)); shown.session.closeColorPicker(false); QCOMPARE(matte.grab().toImage().pixelColor(17, 9), QColor(Qt::white)); QTRY_VERIFY(shown.ready()); // Export closes an open picker, keeping its colour. matte.click(); shown.session.setColorPickerHSB(PickerHSB(PaletteColor{0, 0, 1})); QTRY_VERIFY(shown.ready()); shown.find("exportExport").click(); QVERIFY(!shown.session.colorPicker()); const QImage written = QImage::fromData(shown.answer.value().value().data, "jpeg"); const QColor left = written.pixelColor(8, 16), right = written.pixelColor(56, 16); QVERIFY(left.blue() > 240 && left.red() < 16 && left.green() < 16); QVERIFY(right.red() > 240 && right.blue() < 16); QCOMPARE(shown.answer.value().value().data, ImageExporter::jpeg(halfRed(), {.red = 0, .green = 0, .blue = 1}).data); // Cancel and the sheet going close it too. Sheet cancelled; cancelled.find("exportBackground").click(); cancelled.find("exportCancel").click(); QVERIFY(!cancelled.session.colorPicker() && !cancelled.answer.value()); EditorSession session; auto gone = std::make_unique(halfRed(), session, ExportFormat::jpeg, [](std::optional) {}); gone->findChild()->click(); gone.reset(); QVERIFY(!session.colorPicker()); } void ExportAsSheetTests::exportKeepsTheQualityAndCancelDoesNot() { QSettings().remove(ExportAsSheet::qualityKey); { Sheet shown; shown.find("exportQuality").setValue(40); QTRY_VERIFY(shown.ready()); shown.find("exportCancel").click(); QVERIFY(!shown.answer.value().has_value()); QVERIFY(!QSettings().contains(ExportAsSheet::qualityKey)); } { Sheet shown; QCOMPARE(shown.find("exportQuality").value(), 85); shown.find("exportQuality").setValue(40); QTRY_VERIFY(shown.ready()); shown.find("exportExport").click(); QCOMPARE(QSettings().value(ExportAsSheet::qualityKey).toDouble(), 0.4); } Sheet next; QCOMPARE(next.find("exportQuality").value(), 40); QCOMPARE(next.find("exportPercent").text(), QString("40%")); // Return reaches Export once it is ready. QTRY_VERIFY(next.ready()); QVERIFY(next.find("exportExport").isDefault()); QCOMPARE(next.find("exportExport").text(), QString("Export…")); } void ExportAsSheetTests::theSavedQualityIsReadAsSwiftReadsIt() { const auto started = [](const QVariant &saved) { QSettings().setValue(ExportAsSheet::qualityKey, saved); Sheet shown; return shown.find("exportPercent").text(); }; QCOMPARE(started(0.333), QString("33%")); QCOMPARE(started(0.875), QString("88%")); QCOMPARE(started(7), QString("100%")); QCOMPARE(started(-2), QString("0%")); QCOMPARE(started(QStringLiteral("high")), QString("85%")); QCOMPARE(started(std::nan("")), QString("85%")); QCOMPARE(started(qInf()), QString("85%")); QSettings().remove(ExportAsSheet::qualityKey); } void ExportAsSheetTests::anEncodingThatFailsIsShown() { // JPEG holds at most 65,500 pixels a side. Sheet shown(halfRed(70'000, 1)); auto &error = shown.find("exportError"); QTRY_VERIFY(error.isVisible()); QCOMPARE(error.text(), QString::fromUtf8(ExportError(ExportError::Kind::encode).what())); QCOMPARE(error.foregroundRole(), QPalette::BrightText); QVERIFY(!shown.ready() && !shown.find("exportUpdating").isVisible() && !shown.find("exportSpinner").isVisible()); // A change clears the error while it waits. shown.find("exportQuality").setValue(50); QVERIFY(!error.isVisible()); QCOMPARE(shown.note(), QString("Updating…")); QTRY_VERIFY(error.isVisible()); } void ExportAsSheetTests::eachFormatShowsItsRowsAndWritesItsBytes() { const ExportRaster raster = halfRed(); const auto rows = [](Sheet &shown) { return std::array{shown.find("exportQuality").isVisible(), shown.find("exportPage").isVisible(), shown.find("exportBackground").isVisible()}; }; QSettings().remove(ExportAsSheet::qualityKey); { Sheet shown(raster, ExportFormat::png); QVERIFY(shown.find("exportFormatPNG").isChecked()); QCOMPARE(rows(shown), (std::array{false, false, false})); QCOMPARE(shown.find("exportNote").text(), QString("Transparency kept · sRGB")); QTRY_VERIFY(shown.ready()); QCOMPARE(shown.find("exportBytes").text(), ByteCounts::file(ImageExporter::pngData(raster).size())); shown.find("exportExport").click(); QCOMPARE(shown.answer.value().value().data, ImageExporter::pngData(raster)); QCOMPARE(shown.answer.value().value().format, ExportFormat::png); // Only a JPEG keeps its quality for next time. QVERIFY(!QSettings().contains(ExportAsSheet::qualityKey)); } Sheet shown(raster, ExportFormat::png); QTRY_VERIFY(shown.ready()); // Another format waits for its own result. shown.find("exportFormatPDF").click(); QVERIFY(!shown.ready() && shown.find("exportFormatPDF").isChecked() && !shown.find("exportFormatPNG").isChecked()); QCOMPARE(rows(shown), (std::array{false, true, true})); QCOMPARE(shown.find("exportNote").text(), QString("sRGB")); // 64 by 32 at 144 per inch. QCOMPARE(shown.find("exportPage").text(), QString("0.44 × 0.22 in at 144 DPI")); QTRY_VERIFY(shown.ready()); shown.find("exportExport").click(); QCOMPARE(shown.answer.value().value().data, ImageExporter::pdfData(raster, QColor(Qt::white))); QCOMPARE(shown.answer.value().value().format, ExportFormat::pdf); // The PDF preview lies over its page's white. QCOMPARE(shown.previewAt({100, 165}), QColor(Qt::white)); shown.find("exportFormatJPEG").click(); QCOMPARE(rows(shown), (std::array{true, false, true})); } void ExportAsSheetTests::theSizeKeepsTheProportionsWithinTheLimits() { const ExportRaster raster = halfRed(); Sheet shown(raster, ExportFormat::png); auto &width = shown.find("exportWidth"), &height = shown.find("exportHeight"); auto &scale = shown.find("exportScale"); QVERIFY(width.text() == "64" && height.text() == "32" && scale.text() == "100%"); QCOMPARE(width.toolTip(), QString("Width in pixels; the height follows, keeping the proportions")); const auto type = [](QLineEdit &field, const QString &text) { field.setFocus(); field.selectAll(); field.insert(text); QTest::keyClick(&field, Qt::Key_Return); }; type(width, "32"); QVERIFY(height.text() == "16" && scale.text() == "50%"); QCOMPARE(shown.sheet.settings(), (ExportAsSheet::Settings{ExportFormat::png, shown.sheet.settings().options, 32, 16})); QTRY_VERIFY(shown.ready()); shown.find("exportExport").click(); QCOMPARE(shown.answer.value().value().data, ImageExporter::pngData(ImageExporter::resized(raster, 32, 16))); type(height, "64"); QVERIFY(width.text() == "128" && scale.text() == "200%"); // Other text goes back; a side holds a pixel. type(width, "wide"); QCOMPARE(width.text(), QString("128")); type(width, "0"); QVERIFY(width.text() == "1" && height.text() == "1" && scale.text() == "2%"); // The surface's limit caps a growth: 312.5 times here. type(width, "100000"); QVERIFY(width.text() == "20000" && height.text() == "10000"); // Swift's Int field: past Int32, exponents, fractions truncated. const auto both = [&](QLineEdit &field, const char *typed) { type(field, QString::fromUtf8(typed)); return width.text() + QLatin1Char('x') + height.text(); }; QCOMPARE(both(width, "1e3"), QString("1000x500")); QCOMPARE(both(height, "2147483648"), QString("20000x10000")); QCOMPARE(both(width, " 12.7 "), QString("12x6")); QCOMPARE(both(width, "1E2"), QString("100x50")); QCOMPARE(both(height, "-12.7"), QString("1x1")); QCOMPARE(both(width, "9223372036854775807"), QString("20000x10000")); QCOMPARE(both(width, "9223372036854775807.0"), QString("20000x10000")); QCOMPARE(both(width, "64"), QString("64x32")); QCOMPARE(both(width, "-9.223372036854775808e18"), QString("1x1")); QCOMPARE(both(width, "64"), QString("64x32")); QCOMPARE(both(width, "1e-400"), QString("1x1")); QCOMPARE(both(width, "0.00032e5"), QString("32x16")); QCOMPARE(both(width, "+00128"), QString("128x64")); QCOMPARE(both(width, ".5e2"), QString("50x25")); QCOMPARE(both(width, "5.e1"), QString("50x25")); QCOMPARE(both(width, "١٢٨"), QString("128x64")); QCOMPARE(both(width, "1e+2"), QString("100x50")); QCOMPARE(both(width, "99e-1"), QString("9x5")); QCOMPARE(both(width, "000000000000000000000128"), QString("128x64")); QCOMPARE(both(width, "1.28e"), QString("1x1")); QCOMPARE(both(width, "128e-"), QString("128x64")); QCOMPARE(both(width, "0e30"), QString("1x1")); QCOMPARE(both(width, "64"), QString("64x32")); QCOMPARE(both(width, "1e-99999999999999999999"), QString("1x1")); QCOMPARE(both(width, "64"), QString("64x32")); QCOMPARE(both(width, "1e99999999999999999999"), QString("64x32")); QCOMPARE(both(width, "64"), QString("64x32")); // Past Int64, or grouped, Swift reads nothing here. QCOMPARE(both(width, "9223372036854775808"), QString("64x32")); QCOMPARE(both(width, "1e19"), QString("64x32")); QCOMPARE(both(width, "-1e19"), QString("64x32")); QCOMPARE(both(width, "1,000"), QString("64x32")); QCOMPARE(both(width, "9223372036854775807.5e1"), QString("64x32")); QCOMPARE(both(width, "e5"), QString("64x32")); QCOMPARE(both(width, "."), QString("64x32")); // The menu's steps. QStringList steps; for (QAction *each : scale.menu()->actions()) steps << each->text(); QCOMPARE(steps, (QStringList{"25%", "50%", "75%", "100%", "200%"})); scale.menu()->actions().at(0)->trigger(); QVERIFY(width.text() == "16" && height.text() == "8" && scale.text() == "25%"); // The same size asks nothing new, as Swift's task id. QTRY_VERIFY(shown.ready()); scale.menu()->actions().at(0)->trigger(); QVERIFY(!shown.find("exportWait").isActive()); QCOMPARE(scale.toolTip(), QString("Scale the export: the document stays as it is")); // A hundredth at least: 1120 wide stops at 11. Sheet large(halfRed(1120, 660), ExportFormat::png); auto &wide = large.find("exportWidth"); auto &in = large.find("exportZoomIn"); QVERIFY(in.isEnabled() && in.toolTip().contains("now 50%")); type(wide, "-5"); QVERIFY(wide.text() == "11" && large.find("exportHeight").text() == "7"); // Fit follows the export's size: 11 by 7, 47 times. QVERIFY(!in.isEnabled() && in.toolTip().contains("now 4714%")); } void ExportAsSheetTests::aClearPNGShowsTheCheckerboard() { // 330 square fits the frame's height, one pixel a point. QImage clear(330, 330, QImage::Format_RGBA8888_Premultiplied); clear.fill(Qt::transparent); Sheet shown({clear, 72}, ExportFormat::png); QTRY_VERIFY(shown.ready()); // The image starts at x 115: grays 0.35, 0.30. QCOMPARE(shown.previewAt({116, 1}).rgb(), QColor::fromRgbF(0.35f, 0.35f, 0.35f).rgb()); QCOMPARE(shown.previewAt({126, 1}).rgb(), QColor::fromRgbF(0.30f, 0.30f, 0.30f).rgb()); QCOMPARE(shown.previewAt({126, 11}).rgb(), QColor::fromRgbF(0.35f, 0.35f, 0.35f).rgb()); // Outside the image, the frame's own gray. QCOMPARE(shown.previewAt({100, 165}), QColor(31, 31, 31)); } // Swift 6.0.3's formatter, measured: halves of its digits to even. void ExportAsSheetTests::thePageRoundsAsSwiftsFormatter() { const auto page = [](int width, int height, double dpi) { Sheet shown({halfRed(width, height).image, dpi}, ExportFormat::pdf); return shown.find("exportPage").text(); }; QCOMPARE(page(45, 9, 72), QString("0.62 × 0.12 in at 72 DPI")); QCOMPARE(page(27, 1, 72), QString("0.38 × 0.01 in at 72 DPI")); QCOMPARE(page(1, 3, 200), QString("0 × 0.02 in at 200 DPI")); QCOMPARE(page(27, 29, 200), QString("0.14 × 0.14 in at 200 DPI")); QCOMPARE(page(100, 2999, 300), QString("0.33 × 10 in at 300 DPI")); QCOMPARE(page(30000, 1, 1), QString("30,000 × 1 in at 1 DPI")); } QTEST_MAIN(ExportAsSheetTests) #include "ExportAsSheetTests.moc"