#include "Document/BrushStroke.h" #include "Document/LayerEffects+Renderer.h" #include "Rendering/EffectsPreviewCache.h" #include "RenderFixtures.h" #include #include #include #include // Swift's EffectsPreviewCache: one worker, a delay, the newest request. namespace { QImage filled(int width, int height, const QColor &colour) { QImage image = BrushRaster::context(width, height, false); image.fill(colour); return image; } LayerEffects outline(double size) { LayerEffects effects; effects.stroke = StrokeEffect{.size = size, .red = 1, .green = 1, .blue = 0, .opacity = 1}; return effects; } ImageLayer layerOf(const QImage &image, const LayerEffects &effects) { ImageLayer layer(ImportedImage(image, QImage(), QStringLiteral("Layer")), QPointF(0, 0)); layer.effects = effects; return layer; } // Counts landings, and outlives the cache that calls it. struct Landings { std::shared_ptr count = std::make_shared(0); std::function callback() const { return [count = count] { ++*count; }; } }; std::optional ask(EffectsPreviewCache &cache, const ImageLayer &layer, const Landings &landings, const std::optional &mask = std::nullopt, const std::optional &placement = std::nullopt) { return cache.preview(layer, mask, layer.transform, placement, landings.callback()); } // The result once the worker has had its turn. EffectsPreviewCache::Result landed(EffectsPreviewCache &cache, const ImageLayer &layer, const Landings &landings, int count) { if (!QTest::qWaitFor([&] { return *landings.count == count; }, 5000)) throw std::runtime_error("no preview landed"); return cache.rendered(layer.id).value(); } } class EffectsPreviewCacheTests : public QObject { Q_OBJECT private slots: void aPreviewLandsAfterItsDelayAsTheRendererDrawsIt(); void movingTheLayerKeepsThePreviewUnlessAMaskIsPlaced(); void settingsKeepTheLastPreviewButNewPixelsDropIt(); void onlyTheNewestRequestLands(); void aResultLandsOnlyOnTheRequestThatAskedForIt(); void seedsStandInUntilTheWorkerCatchesUp(); void preparingDropsLayersWithoutEffectsAndSharesTheBudget(); void aMaskHidesPixelsInTheReducedPreview(); void aReducedPreviewScalesEveryLength(); void aClosedCacheLandsNothing(); }; void EffectsPreviewCacheTests::aPreviewLandsAfterItsDelayAsTheRendererDrawsIt() { EffectsPreviewCache cache; const QImage image = filled(20, 10, QColor(0, 0, 255)); const ImageLayer layer = layerOf(image, outline(3)); const Landings landings; QElapsedTimer clock; clock.start(); QVERIFY(!ask(cache, layer, landings) && !cache.rendered(layer.id)); const EffectsPreviewCache::Result result = landed(cache, layer, landings, 1); QVERIFY(clock.elapsed() >= 60); // Small enough to render whole: the export's own pixels. const LayerEffectsRenderer::Rendered expected = LayerEffectsRenderer::render(image, std::nullopt, outline(3)); QVERIFY(result.image == expected.image && result.inset == 5 && expected.inset == 5 && !result.placement); // Asked again, the same pixels come back and nothing renders. QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), result.image.cacheKey()); QTest::qWait(150); QCOMPARE(*landings.count, 1); // No effects, or none shown: nothing, and the entry goes. ImageLayer hidden = layer; hidden.effects->stroke->enabled = false; QVERIFY(!ask(cache, hidden, landings) && !cache.rendered(layer.id)); QTest::qWait(150); QVERIFY(!cache.rendered(layer.id)); // No pixels, or effects out of bounds: nothing either. ImageLayer blank(QStringLiteral("Blank"), QSizeF(20, 10)); blank.effects = outline(3); QVERIFY(!ask(cache, blank, landings)); QVERIFY(!ask(cache, layerOf(image, outline(600)), landings)); QTest::qWait(150); QCOMPARE(*landings.count, 1); } void EffectsPreviewCacheTests::movingTheLayerKeepsThePreviewUnlessAMaskIsPlaced() { EffectsPreviewCache cache; ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); layer.mask = LayerMask(LayerMask::assetFrom(gray(20, 10, 255))); const QImage mask = layer.mask->enabledImage().value(); const Landings landings; ask(cache, layer, landings, mask); const qint64 first = landed(cache, layer, landings, 1).image.cacheKey(); // Effects render in layer pixels: a move only moves them. layer.transform.origin = QPointF(7, 3); QCOMPARE(ask(cache, layer, landings, mask).value().image.cacheKey(), first); QTest::qWait(150); QCOMPARE(*landings.count, 1); // A mask placed apart resamples, the last preview shown meanwhile. const LayerTransform placement = placedAt(QPointF(2, 2), QSizeF(20, 10)); QCOMPARE(ask(cache, layer, landings, mask, placement).value().image.cacheKey(), first); const qint64 second = landed(cache, layer, landings, 2).image.cacheKey(); layer.transform.origin = QPointF(9, 3); QCOMPARE(ask(cache, layer, landings, mask, placement).value().image.cacheKey(), second); QVERIFY(landed(cache, layer, landings, 3).image.cacheKey() != second); // Without a mask the placement changes nothing. ImageLayer bare = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); ask(cache, bare, landings, std::nullopt, placement); const qint64 unmasked = landed(cache, bare, landings, 4).image.cacheKey(); bare.transform.origin = QPointF(1, 1); QCOMPARE(ask(cache, bare, landings, std::nullopt, placedAt(QPointF(5, 5), QSizeF(20, 10))).value().image.cacheKey(), unmasked); QTest::qWait(150); QCOMPARE(*landings.count, 4); } void EffectsPreviewCacheTests::settingsKeepTheLastPreviewButNewPixelsDropIt() { EffectsPreviewCache cache; ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); const Landings landings; ask(cache, layer, landings); const qint64 first = landed(cache, layer, landings, 1).image.cacheKey(); // Same kinds: the old preview shows while the new renders. layer.effects = outline(5); QCOMPARE(ask(cache, layer, landings).value().image.cacheKey(), first); QCOMPARE(landed(cache, layer, landings, 2).inset, 7.0); // Another kind: nothing is shown until it lands. layer.effects->colorOverlay = ColorOverlayEffect(); QVERIFY(!ask(cache, layer, landings)); landed(cache, layer, landings, 3); // New pixels, or a mask switched on, drop it too. layer.asset = ImportedImage(filled(20, 10, QColor(255, 0, 0)), QImage(), QStringLiteral("Red")); QVERIFY(!ask(cache, layer, landings)); landed(cache, layer, landings, 4); layer.mask = LayerMask(LayerMask::assetFrom(gray(20, 10, 255))); QVERIFY(!ask(cache, layer, landings, layer.mask->enabledImage())); landed(cache, layer, landings, 5); // A mask switched off is no source. layer.mask->isEnabled = false; QVERIFY(!ask(cache, layer, landings)); landed(cache, layer, landings, 6); } void EffectsPreviewCacheTests::onlyTheNewestRequestLands() { EffectsPreviewCache cache; ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); const Landings older, newer; ask(cache, layer, older); layer.effects = outline(6); ask(cache, layer, newer); QCOMPARE(landed(cache, layer, newer, 1).inset, 8.0); QTest::qWait(150); QCOMPARE(*older.count, 0); } void EffectsPreviewCacheTests::aResultLandsOnlyOnTheRequestThatAskedForIt() { EffectsPreviewCache cache; ImageLayer layer = layerOf(filled(1500, 1500, QColor(0, 0, 255)), outline(40)); const Landings older, newer; ask(cache, layer, older); // Its render finishes while the UI thread is busy elsewhere. QTest::qWait(70); QThread::msleep(2000); layer.effects = outline(6); ask(cache, layer, newer); QCOMPARE(landed(cache, layer, newer, 1).inset, 8.0); QCOMPARE(*older.count, 0); // A seed meanwhile leaves the late result nowhere to land. layer.effects = outline(9); const Landings late; ask(cache, layer, late); QTest::qWait(70); QThread::msleep(2000); cache.seed(layer.id, filled(4, 4, Qt::green), placedAt(QPointF(0, 0), QSizeF(4, 4))); QTest::qWait(150); QVERIFY(*late.count == 0 && cache.rendered(layer.id).value().placement); } void EffectsPreviewCacheTests::seedsStandInUntilTheWorkerCatchesUp() { EffectsPreviewCache cache; const ImageLayer layer = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); const Landings dropped, landings; ask(cache, layer, dropped); // A seed replaces the render under way, which never lands. const QImage warped = filled(30, 12, QColor(0, 255, 0)); const LayerTransform placement = placedAt(QPointF(4, 5), QSizeF(30, 12)); cache.seed(layer.id, warped, placement); EffectsPreviewCache::Result seeded = cache.rendered(layer.id).value(); QVERIFY(seeded.image.cacheKey() == warped.cacheKey() && seeded.inset == 0 && seeded.placement == placement); // It stands in for the next request, then gives way. seeded = ask(cache, layer, landings).value(); QVERIFY(seeded.image.cacheKey() == warped.cacheKey() && seeded.placement == placement); const EffectsPreviewCache::Result fresh = landed(cache, layer, landings, 1); QVERIFY(fresh.image.cacheKey() != warped.cacheKey() && !fresh.placement); QCOMPARE(*dropped.count, 0); // Gone for good: new pixels find neither. ImageLayer repainted = layer; repainted.asset = ImportedImage(filled(20, 10, QColor(255, 0, 0)), QImage(), QStringLiteral("Red")); QVERIFY(!ask(cache, repainted, landings)); } void EffectsPreviewCacheTests::preparingDropsLayersWithoutEffectsAndSharesTheBudget() { EffectsPreviewCache cache; const ImageLayer wide = layerOf(filled(6000, 100, QColor(0, 0, 255)), outline(4)); const ImageLayer small = layerOf(filled(20, 10, QColor(0, 0, 255)), outline(3)); const Landings landings; cache.prepare({wide, small}); ask(cache, wide, landings); ask(cache, small, landings); QVERIFY(QTest::qWaitFor([&] { return *landings.count == 2; }, 5000)); // Two layers with effects: 1536 a side, margins included. EffectsPreviewCache::Result result = cache.rendered(wide.id).value(); QVERIFY(result.image.size() == QSize(1533, 33) && result.inset == 4); // 1024 layers share 128 a side; twenty thousand get 32. std::vector many(1024, wide); for (ImageLayer &layer : many) layer.id = QUuid::createUuid(); many.front() = wide; cache.prepare(many); ask(cache, wide, landings); result = landed(cache, wide, landings, 3); QVERIFY(result.image.size() == QSize(126, 8) && result.inset == 3); many.resize(20000, many.back()); for (ImageLayer &layer : many) layer.id = QUuid::createUuid(); many.front() = wide; cache.prepare(many); ask(cache, wide, landings); result = landed(cache, wide, landings, 4); QVERIFY(result.image.size() == QSize(30, 7) && result.inset == 3); // Layers without effects shown go, seeds too; none leaves 1536. ask(cache, small, landings); landed(cache, small, landings, 5); ImageLayer off = small; off.effects->stroke->enabled = false; cache.seed(wide.id, filled(4, 4, Qt::green), placedAt(QPointF(0, 0), QSizeF(4, 4))); cache.prepare({off}); QVERIFY(!cache.rendered(small.id) && !cache.rendered(wide.id)); ask(cache, wide, landings); QCOMPARE(landed(cache, wide, landings, 6).image.size(), QSize(1533, 33)); } void EffectsPreviewCacheTests::aMaskHidesPixelsInTheReducedPreview() { EffectsPreviewCache cache; ImageLayer layer = layerOf(filled(3000, 100, QColor(0, 0, 255)), outline(4)); // Left half hidden: the preview shows the right half. QImage mask = gray(3000, 100, 255); for (int y = 0; y < 100; ++y) std::fill(mask.scanLine(y), mask.scanLine(y) + 1500, uchar(0)); const Landings landings; cache.prepare({layer}); ask(cache, layer, landings, mask); const QImage image = landed(cache, layer, landings, 1).image; QVERIFY(image.size() == QSize(1532, 61)); QCOMPARE(qAlpha(image.pixel(300, 30)), 0); QCOMPARE(image.pixel(1200, 30), qRgba(0, 0, 255, 255)); // The outline runs round what shows, at the halfway line. QCOMPARE(image.pixel(764, 30), qRgba(255, 255, 0, 255)); // A mask of another size is reduced by its own. QImage coarse(2, 1, QImage::Format_Grayscale8); coarse.scanLine(0)[0] = 0; coarse.scanLine(0)[1] = 255; layer.asset = ImportedImage(filled(6000, 100, QColor(0, 0, 255)), QImage(), QStringLiteral("Wide")); ask(cache, layer, landings, coarse); const QImage wide = landed(cache, layer, landings, 2).image; QVERIFY(wide.size() == QSize(1533, 33)); QCOMPARE(qAlpha(wide.pixel(104, 16)), 0); QCOMPARE(wide.pixel(1404, 16), qRgba(0, 0, 255, 255)); // Stripes a row high shrink across and grow down, unaliased. QImage stripes(6000, 1, QImage::Format_Grayscale8); for (int x = 0; x < 6000; ++x) stripes.scanLine(0)[x] = uchar(x % 2 ? 255 : 0); layer.mask = LayerMask(LayerMask::assetFrom(stripes)); ask(cache, layer, landings, stripes); const QImage row = landed(cache, layer, landings, 3).image; int low = 255, high = 0; for (int x = 10; x < 1523; ++x) { low = std::min(low, qAlpha(row.pixel(x, 16))); high = std::max(high, qAlpha(row.pixel(x, 16))); } QVERIFY(low == 125 && high == 129); layer.mask = LayerMask(LayerMask::assetFrom(stripes.scaled(6000, 100))); ask(cache, layer, landings, layer.mask->enabledImage()); QVERIFY(landed(cache, layer, landings, 4).image == row); } void EffectsPreviewCacheTests::aReducedPreviewScalesEveryLength() { EffectsPreviewCache cache; LayerEffects effects; effects.stroke = StrokeEffect{.size = 4, .red = 1, .green = 1, .blue = 0, .opacity = 1}; effects.shadow = ShadowEffect{.angle = 45, .distance = 8, .blur = 3, .red = 1, .green = 0, .blue = 0, .opacity = 0.8}; effects.colorOverlay = ColorOverlayEffect{.red = 0, .green = 1, .blue = 0, .opacity = 0.5}; effects.innerShadow = InnerShadowEffect{.angle = 120, .distance = 30, .blur = 12, .red = 0, .green = 0, .blue = 0, .opacity = 0.7}; const QImage image = noise(6000, 100, 7); const ImageLayer layer = layerOf(image, effects); const Landings landings; cache.prepare({layer}); ask(cache, layer, landings); const EffectsPreviewCache::Result result = landed(cache, layer, landings, 1); // The renderer on the reduced pixels, every length reduced alike. const double factor = 1528.0 / (6000 + 2 * LayerEffectsRenderer::margin(effects)); const QImage reduced = image.scaled(1518, 25, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); effects.stroke->size *= factor; effects.shadow->distance *= factor; effects.shadow->blur *= factor; effects.innerShadow->distance *= factor; effects.innerShadow->blur *= factor; QVERIFY(result.image == LayerEffectsRenderer::render(reduced, std::nullopt, effects).image); } void EffectsPreviewCacheTests::aClosedCacheLandsNothing() { const Landings landings; { EffectsPreviewCache cache; ask(cache, layerOf(filled(1500, 1500, QColor(0, 0, 255)), outline(40)), landings); QTest::qWait(70); } QTest::qWait(150); QCOMPARE(*landings.count, 0); } QTEST_GUILESS_MAIN(EffectsPreviewCacheTests) #include "EffectsPreviewCacheTests.moc"