#include "BudgetFixtures.h" #include "BrushFixtures.h" #include "IO/ImageExporter.h" #include "IO/ProjectStore.h" #include "SelectionFixtures.h" #include "Rendering/RasterSnapshot.h" #include "SessionRecord.h" #include // The session's pixel edits: strokes, erase, clears, fills. namespace { // Swift's makeSession: a blank layer, the Brush, hard red 20. std::unique_ptr makeSession(int width = 600, int height = 80) { auto session = std::make_unique(); session->createDocument(width, height, true); session->addBlankLayer(); session->selectTool(NavigationTool::brush); session->setBrushSettings(brush(20, 1, 1, 0, 0)); return session; } QImage render(const EditorSession &session) { return ImageExporter::render(session.projectSnapshot().value()).image; } QImage live(const EditorSession &session) { return preview(*session.brushStroke(), session.document().value().size()); } void select(EditorSession &session, const QRectF &rect) { session.applySelection(rectPath(rect), SelectionMode::replace, "Select"); } } class PixelEditTests : public QObject { Q_OBJECT private slots: void aStrokeHoldsTheSessionAndShiftLinesStartWhereItEnded(); void eraseClearsAndDeleteFillsAndClears(); void brushCallsAnnounceAndRefuseInSilence(); void aRasterEditLandsOnlyOnTheLayerItReadAndTheBudgetCountsEveryLayer(); void aMaskSwitchedOffMeanwhileStaysOff(); }; void PixelEditTests::aStrokeHoldsTheSessionAndShiftLinesStartWhereItEnded() { const auto session = makeSession(); const QUuid id = session->activeLayerID().value(); QVERIFY(!session->shiftLineStart().has_value()); session->addLayerMask(true); session->selectLayerTarget(id, false); session->beginBrush(QPointF(20, 40)); // While the stroke lasts nothing else moves. QVERIFY(!session->canEditLayers() && !session->canUseHistory() && !session->canStartProjectOperation()); QVERIFY(!session->canInvert() && !session->canAdjustColors() && !session->canContentAwareFill()); const int count = session->history.undoCount(); bool inverted = false; session->invertPixels([&] { inverted = true; }); QTRY_VERIFY(inverted); QVERIFY(session->brushStroke() && session->history.undoCount() == count); session->selectTool(NavigationTool::move); QCOMPARE(session->tool(), NavigationTool::brush); session->selectLayer(session->document().value().layers.front().id); QCOMPARE(session->activeLayerID(), std::optional(id)); session->selectLayerTarget(id, true); QVERIFY(!session->isMaskSelected()); session->continueBrush(QPointF(100, 40)); QCOMPARE(session->shiftLineStart(), std::optional(QPointF(100, 40))); // Busy, the mouse-up waits; free, it commits. session->setIsProjectBusy(true); QVERIFY(!session->finishBrushImmediately()); session->setIsProjectBusy(false); QVERIFY(session->finishBrushImmediately()); QVERIFY(session->finishBrushImmediately()); QCOMPARE(session->shiftLineStart(), std::optional(QPointF(100, 40))); session->selectLayer(session->document().value().layers.front().id); QVERIFY(!session->shiftLineStart().has_value()); session->selectLayer(id); session->selectLayerTarget(id, true); QVERIFY(session->isMaskSelected() && !session->shiftLineStart().has_value()); // A Shift-click's line: the old end to the new point. session->selectLayerTarget(id, false); session->beginBrush(session->shiftLineStart().value()); session->continueBrush(QPointF(300, 40)); session->finishBrush(); QCOMPARE(alpha(render(*session), 200, 40), 255); // Two selected layers paint nothing; an import ends a stroke. const QUuid other = session->document().value().layers.front().id; session->selectLayers({id, other}, id); QVERIFY(!session->canPaint()); session->selectLayer(id); QTemporaryDir folder; const QString path = folder.filePath(QStringLiteral("dot.png")); QImage dot(2, 2, QImage::Format_RGBA8888_Premultiplied); dot.fill(Qt::green); QVERIFY(dot.save(path)); session->beginBrush(QPointF(400, 40)); bool imported = false; session->importImages({QUrl::fromLocalFile(path)}, std::nullopt, [&] { imported = true; }); QVERIFY(!session->brushStroke()); QCOMPARE(session->history.undoName(), QString("Brush Stroke")); QTRY_VERIFY(imported); QCOMPARE(session->activeLayer().value().name, QString("dot")); } void PixelEditTests::eraseClearsAndDeleteFillsAndClears() { const auto session = makeSession(20, 10); QImage red = BrushRaster::context(20, 10, false); red.fill(Qt::red); session->insert(ImportedImage(red, red, "Red")); session->selectTool(NavigationTool::brush); // Pixels to invert or fill, refused while a stroke lasts. QVERIFY(session->canInvert() && session->canAdjustColors()); session->beginBrush(QPointF(15, 5)); QVERIFY(!session->canInvert() && !session->canAdjustColors()); session->cancelBrush(); QVERIFY(session->canInvert() && session->canAdjustColors()); // Erase takes the pixels out; the mask keeps painting. session->setBrushMode(BrushToolMode::erase); session->setBrushSettings(brush(4, 1, 0, 0, 1)); session->beginBrush(QPointF(5, 5)); QCOMPARE(alpha(live(*session), 5, 5), 0); session->finishBrush(); QCOMPARE(session->history.undoName(), QString("Erase")); QCOMPARE(alpha(render(*session), 5, 5), 0); QCOMPARE(pixel(render(*session), 15, 5), (std::vector{255, 0, 0, 255})); session->undo(); // Delete with a selection clears; without, it deletes. session->selectTool(NavigationTool::marquee); select(*session, QRectF(0, 0, 10, 10)); bool done = false; session->clearSelectedPixels([&] { done = true; }); QTRY_VERIFY(done); QCOMPARE(session->history.undoName(), QString("Clear")); QCOMPARE(alpha(render(*session), 5, 5), 0); QCOMPARE(pixel(render(*session), 15, 5), (std::vector{255, 0, 0, 255})); session->undo(); done = false; session->fillSelection(EditorSession::FillSource::background, [&] { done = true; }); QTRY_VERIFY(done); QCOMPARE(session->history.undoName(), QString("Fill")); QCOMPARE(pixel(render(*session), 5, 5), (std::vector{255, 255, 255, 255})); session->undo(); session->deleteKeyPressed(); QTRY_COMPARE(session->history.undoName(), QString("Clear")); session->deselect(); const int layers = int(session->document().value().layers.size()); session->deleteKeyPressed(); QCOMPARE(int(session->document().value().layers.size()), layers - 1); // Back past the delete, deselect and clear: red again. session->undo(); session->undo(); session->undo(); QCOMPARE(pixel(render(*session), 5, 5), (std::vector{255, 0, 0, 255})); // A mask target fills with the background: black's is white. session->selectLayer(session->document().value().layers.back().id); session->addLayerMask(true); select(*session, QRectF(0, 0, 10, 10)); session->setMaskPaintWhite(false); done = false; session->clearSelectedPixels([&] { done = true; }); QTRY_VERIFY(done); QCOMPARE(session->history.undoName(), QString("Fill Mask")); QCOMPARE(alpha(render(*session), 5, 5), 255); session->setMaskPaintWhite(true); done = false; session->clearSelectedPixels([&] { done = true; }); QTRY_VERIFY(done); QCOMPARE(alpha(render(*session), 5, 5), 0); } void PixelEditTests::brushCallsAnnounceAndRefuseInSilence() { const auto session = makeSession(); QSignalSpy changes(session.get(), &EditorSession::changed); const auto announced = [&](const std::function &change) { const qsizetype before = changes.count(); change(); return changes.count() > before; }; QVERIFY(!announced([&] { session->continueBrush(QPointF(1, 1)); })); QVERIFY(announced([&] { session->changeBrushSize(true); })); QVERIFY(announced([&] { session->setBrushMode(BrushToolMode::erase); })); QVERIFY(announced([&] { session->setMaskPaintWhite(true); })); QVERIFY(announced([&] { session->typeOpacityDigit(5, 1); })); QVERIFY(announced([&] { session->beginBrush(QPointF(10, 10)); })); QVERIFY(!announced([&] { session->selectTool(NavigationTool::move); })); QVERIFY(!announced([&] { session->typeOpacityDigit(5, 2); })); QVERIFY(announced([&] { session->continueBrush(QPointF(20, 10)); })); QVERIFY(announced([&] { session->cancelBrush(); })); // The last signal of a change sees what it leaves. QStringList seen; connect(session.get(), &EditorSession::changed, session.get(), [&] { seen = described(*session); }); session->beginBrush(QPointF(10, 10)); QCOMPARE(seen, described(*session)); session->continueBrush(QPointF(50, 10)); QCOMPARE(seen, described(*session)); session->finishBrush(); QCOMPARE(seen, described(*session)); session->changeBrushHardness(false); QCOMPARE(seen, described(*session)); } void PixelEditTests::aRasterEditLandsOnlyOnTheLayerItReadAndTheBudgetCountsEveryLayer() { const auto session = makeSession(20, 10); QImage red = BrushRaster::context(20, 10, false); red.fill(Qt::red); session->insert(ImportedImage(red, red, "Red")); const QUuid id = session->activeLayerID().value(); QImage white = BrushRaster::context(20, 10, true); white.fill(Qt::white); rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(white)); }); session->selectLayerTarget(id, false); select(*session, QRectF(0, 0, 10, 10)); // The mask replaced while the clear flies: nothing lands. bool done = false; session->clearSelectedPixels([&] { done = true; }); QVERIFY(session->isProjectBusy()); QImage black = BrushRaster::context(20, 10, true); rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(black)); }); QTRY_VERIFY(done); QVERIFY(!session->isProjectBusy()); QCOMPARE(session->history.undoCount(), 0); QCOMPARE(int(layerWith(*session, id).mask.value().asset.image().constScanLine(5)[5]), 0); QCOMPARE(pixel(layerWith(*session, id).asset.value().image(), 5, 5), (std::vector{255, 0, 0, 255})); // The other layers leave a stroke 4 million pixels. const auto big = std::make_unique(); big->createDocument(4000, 4000, true); const QUuid first = big->activeLayerID().value(); QSet claims; for (const ImportedImage &layer : claiming(DocumentLimits::documentPixelBudget() - 4'000'000)) { big->insert(layer); claims.insert(big->activeLayerID().value()); } big->addBlankLayer(); big->selectTool(NavigationTool::brush); QCOMPARE(big->makeRasterEdit(big->activeLayer().value())->pixelLimit, qint64(4'000'000)); // A 2000 px tip needs more tiles than that: refused. big->setBrushSettings(brush(2000, 1, 1, 0, 0)); big->beginBrush(QPointF(2000, 2000)); QVERIFY(!big->brushStroke() && big->brushError().has_value()); big->setBrushError(std::nullopt); big->setBrushSettings(brush(20, 1, 1, 0, 0)); big->beginBrush(QPointF(2000, 2000)); QVERIFY(big->brushStroke()); big->cancelBrush(); // A masked layer's budget counts the other masks too. const QUuid last = big->activeLayerID().value(); rewrite(*big, [&](ProjectSnapshot &snapshot) { for (ProjectLayerRecord &record : snapshot.manifest.layers) setMask(snapshot, record.id, claims.contains(record.id) ? claimed(snapshot.images.at(record.id).size().width(), snapshot.images.at(record.id).size().height()) : claimed(4000, 4000)); }); QVERIFY(layerWith(*big, first).mask); big->selectLayerTarget(last, false); // The claims' masks and a 16-million one: below zero. QCOMPARE(big->makeRasterEdit(big->activeLayer().value())->pixelLimit, qint64(-12'000'000)); } void PixelEditTests::aMaskSwitchedOffMeanwhileStaysOff() { const auto session = makeSession(20, 10); QImage red = BrushRaster::context(20, 10, false); red.fill(Qt::red); session->insert(ImportedImage(red, red, "Red")); const QUuid id = session->activeLayerID().value(); QImage white = BrushRaster::context(20, 10, true); white.fill(Qt::white); rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(white)); }); session->selectLayerTarget(id, false); select(*session, QRectF(0, 0, 10, 10)); bool done = false; session->clearSelectedPixels([&] { done = true; }); QVERIFY(session->isProjectBusy()); // Only the switch changes meanwhile: same pixels, same place. rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = false; }); QTRY_VERIFY(done); QVERIFY(!session->isProjectBusy()); QCOMPARE(session->history.undoCount(), 1); QCOMPARE(session->history.undoName(), QString("Clear")); QVERIFY(!layerWith(*session, id).mask.value().isEnabled); // The commit crops the cleared half away: read the document. QCOMPARE(pixel(render(*session), 5, 5)[3], 0); QCOMPARE(pixel(render(*session), 15, 5), (std::vector{255, 0, 0, 255})); } QTEST_GUILESS_MAIN(PixelEditTests) #include "PixelEditTests.moc"