Files
OmaPhoto/tests/SelectionSourceTests.cpp
T
雷电芽衣andClaude c864304695
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s
OmaPhoto 1.0.0: the Linux port of Compositor
A layered image editor for Linux and Omarchy, ported from Compositor
(github.com/robbietilton/Compositor), whose Swift source is its
specification. It reads and writes Compositor's .comp projects.

Packages for Ubuntu, Fedora and Arch come from the release workflow;
scripts/install/ holds one install script per distribution family,
and flake.nix serves NixOS.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-26 00:44:52 -04:00

242 lines
11 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "AddressSpaceLimit.h"
#include "RenderFixtures.h"
#include "SelectionFixtures.h"
// The Marquee's boxes and thumbnails loaded as selections.
namespace {
void marquee(EditorSession &session, QPointF start, QPointF end, SelectionMode mode = SelectionMode::replace, bool square = false, bool fromCenter = false)
{
session.beginLasso(start, mode);
session.dragMarquee(end, square, fromCenter);
session.finishLasso();
}
// A mask: a black square with a white hole.
QUuid maskedLayer(EditorSession &session)
{
const QUuid id = session.activeLayerID().value();
QImage mask = BrushRaster::context(100, 100, true);
mask.fill(Qt::white);
QPainter painter(&mask);
painter.fillRect(QRect(20, 30, 40, 40), Qt::black);
painter.fillRect(QRect(30, 40, 10, 10), Qt::white);
painter.end();
rewrite(session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, LayerMask::assetFrom(mask)); });
return id;
}
}
class SelectionSourceTests : public QObject {
Q_OBJECT
private slots:
void marqueeDrawsWholePixelRectanglesInAnyDirection();
void marqueeShiftMakesSquaresAndCenteredDragsGrowFromTheAnchor();
void marqueeEllipseSelectsAnOvalInItsBoxAndShiftMakesACircle();
void ctrlClickingAMaskSelectsItsBlackAreas();
void maskSelectionFollowsTheLayerTransformAndIgnoresAllWhiteMasks();
void ctrlClickingALayerSelectsItsOpaquePixels();
void paintedPixelsThatCannotFlattenLoadNothing();
};
void SelectionSourceTests::marqueeDrawsWholePixelRectanglesInAnyDirection()
{
const auto session = selectionSession();
session->selectTool(NavigationTool::marquee);
marquee(*session, QPointF(60.4, 70.6), QPointF(20.2, 30.3));
QCOMPARE(session->history.undoName(), QString("Rectangular Marquee"));
QCOMPARE(bounds(*session), QRectF(20, 30, 40, 41));
QCOMPARE(coverage(*session, 20, 30), 255);
QCOMPARE(coverage(*session, 19, 30), 0);
marquee(*session, QPointF(80, 80), QPointF(90, 90), SelectionMode::add);
QCOMPARE(coverage(*session, 85, 85), 255);
QCOMPARE(coverage(*session, 40, 50), 255);
marquee(*session, QPointF(30, 40), QPointF(50, 60), SelectionMode::subtract);
QCOMPARE(coverage(*session, 40, 50), 0);
QCOMPARE(coverage(*session, 25, 35), 255);
// A click deselects; a wild drag point is ignored.
session->beginLasso(QPointF(5, 5), SelectionMode::replace);
session->dragMarquee(QPointF(std::nan(""), 5), false, false);
QCOMPARE(session->lassoDraft().value().points.size(), size_t(1));
session->finishLasso();
QVERIFY(!session->selection().has_value());
// A lasso draft takes no marquee drag.
session->selectTool(NavigationTool::lasso);
session->beginLasso(QPointF(5, 5), SelectionMode::replace);
session->dragMarquee(QPointF(50, 50), false, false);
QCOMPARE(session->lassoDraft().value().points.size(), size_t(1));
QVERIFY(!session->lassoDraft().value().anchor.has_value());
session->cancelLasso();
}
void SelectionSourceTests::marqueeShiftMakesSquaresAndCenteredDragsGrowFromTheAnchor()
{
const auto session = selectionSession();
session->selectTool(NavigationTool::marquee);
marquee(*session, QPointF(10, 10), QPointF(40, 20), SelectionMode::replace, true);
QCOMPARE(bounds(*session), QRectF(10, 10, 30, 30));
marquee(*session, QPointF(50, 50), QPointF(60, 55), SelectionMode::replace, false, true);
QCOMPARE(bounds(*session), QRectF(40, 45, 20, 10));
marquee(*session, QPointF(50, 50), QPointF(45, 58), SelectionMode::replace, true, true);
QCOMPARE(bounds(*session), QRectF(42, 42, 16, 16));
QVERIFY(isSelectionTool(NavigationTool::marquee) && !isSelectionTool(NavigationTool::brush));
// The anchor rounds; the box follows the drag's sign.
QCOMPARE(DragBox::rect(QPointF(10, 10), QPointF(3.4, 15.6), false, false), QRectF(3, 10, 7, 6));
QCOMPARE(DragBox::rect(QPointF(10, 10), QPointF(3, 16), true, false), QRectF(3, 10, 7, 7));
}
void SelectionSourceTests::marqueeEllipseSelectsAnOvalInItsBoxAndShiftMakesACircle()
{
const auto session = selectionSession();
session->selectTool(NavigationTool::marquee);
session->toggleMarqueeKind();
QVERIFY(session->marqueeKind() == LassoKind::ellipse);
session->beginLasso(QPointF(10, 20), SelectionMode::replace);
session->dragMarquee(QPointF(70, 60), false, false);
session->finishLasso();
QCOMPARE(session->history.undoName(), QString("Elliptical Marquee"));
const QRectF box = bounds(*session);
QVERIFY(std::abs(box.left() - 10) < 0.5 && std::abs(box.right() - 70) < 0.5 && std::abs(box.top() - 20) < 0.5 && std::abs(box.bottom() - 60) < 0.5);
QCOMPARE(coverage(*session, 40, 40), 255);
// The box's corner lies outside the oval.
QCOMPARE(coverage(*session, 11, 21), 0);
session->beginLasso(QPointF(5, 5), SelectionMode::replace);
session->dragMarquee(QPointF(45, 25), true, false);
session->finishLasso();
const QRectF circle = bounds(*session);
QVERIFY(std::abs(circle.width() - circle.height()) < 0.5);
// A click with the ellipse deselects too.
marquee(*session, QPointF(5, 5), QPointF(5, 5));
QVERIFY(!session->selection().has_value());
session->beginLasso(QPointF(1, 1), SelectionMode::replace);
session->toggleMarqueeKind();
QVERIFY(session->marqueeKind() == LassoKind::rectangle && !session->lassoDraft().has_value());
}
void SelectionSourceTests::ctrlClickingAMaskSelectsItsBlackAreas()
{
const auto session = selectionSession();
const QUuid id = maskedLayer(*session);
session->loadMaskSelection(id);
QCOMPARE(session->history.undoName(), QString("Load Mask Selection"));
// Black is selected; the white hole and surroundings are not.
QCOMPARE(coverage(*session, 25, 35), 255);
QCOMPARE(coverage(*session, 35, 45), 0);
QCOMPARE(coverage(*session, 80, 80), 0);
// Exact pixel edges.
QCOMPARE(coverage(*session, 59, 69), 255);
QCOMPARE(coverage(*session, 60, 70), 0);
// Shift adds and Alt subtracts.
lasso(*session, square(80, 80, 10));
session->loadMaskSelection(id, SelectionMode::add);
QCOMPARE(coverage(*session, 85, 85), 255);
QCOMPARE(coverage(*session, 25, 35), 255);
session->loadMaskSelection(id, SelectionMode::subtract);
QCOMPARE(coverage(*session, 85, 85), 255);
QCOMPARE(coverage(*session, 25, 35), 0);
// An unknown layer, or one without a mask, loads nothing.
const int count = session->history.undoCount();
EditorSession empty;
empty.loadMaskSelection(id);
empty.loadLayerSelection(id);
QVERIFY(!empty.document().has_value());
session->loadMaskSelection(QUuid::createUuid());
session->addBlankLayer();
session->loadMaskSelection(session->activeLayerID().value());
QCOMPARE(session->history.undoCount(), count + 1);
}
void SelectionSourceTests::maskSelectionFollowsTheLayerTransformAndIgnoresAllWhiteMasks()
{
const auto session = selectionSession(200, 200);
const QUuid id = maskedLayer(*session);
// The layer and its mask stretched 2× from the origin.
rewrite(*session, [&](ProjectSnapshot &snapshot) {
record(snapshot, id).transform.origin = QPointF(0, 0);
record(snapshot, id).transform.size = QSizeF(200, 200);
});
session->loadMaskSelection(id);
QCOMPARE(bounds(*session), QRectF(40, 60, 80, 80));
// A mask placed apart loads where it shows.
rewrite(*session, [&](ProjectSnapshot &snapshot) {
record(snapshot, id).maskPlacement = LayerTransform{.origin = {10, 0}, .size = {100, 100}};
record(snapshot, id).maskLinked = false;
});
session->loadMaskSelection(id);
QCOMPARE(bounds(*session), QRectF(30, 30, 40, 40));
session->deselect();
session->addBlankLayer();
session->addLayerMask(true);
// No black anywhere: nothing to select.
session->loadMaskSelection(session->activeLayerID().value());
QVERIFY(!session->selection().has_value());
}
void SelectionSourceTests::ctrlClickingALayerSelectsItsOpaquePixels()
{
const auto session = selectionSession(200, 200);
// An opaque ring, a clear centre, a faint corner pixel.
QImage image = BrushRaster::context(50, 50, false);
QPainter painter(&image);
painter.fillRect(QRect(10, 10, 30, 30), Qt::red);
painter.setCompositionMode(QPainter::CompositionMode_Source);
painter.fillRect(QRect(20, 20, 10, 10), Qt::transparent);
painter.fillRect(QRect(0, 0, 1, 1), QColor(255, 0, 0, 64));
painter.end();
session->insert(ImportedImage(image, image, "Ring"));
const QUuid id = session->activeLayerID().value();
rewrite(*session, [&](ProjectSnapshot &snapshot) {
record(snapshot, id).transform.origin = QPointF(50, 50);
record(snapshot, id).transform.size = QSizeF(100, 100);
});
session->loadLayerSelection(id);
QCOMPARE(session->history.undoName(), QString("Load Layer Selection"));
// Twice the size: the ring's box doubles.
QCOMPARE(bounds(*session), QRectF(70, 70, 60, 60));
QCOMPARE(coverage(*session, 75, 75), 255);
QCOMPARE(coverage(*session, 100, 100), 0);
// The 25% pixel is under the threshold.
QCOMPARE(coverage(*session, 50, 50), 0);
lasso(*session, square(0, 0, 20));
session->loadLayerSelection(id, SelectionMode::add);
QCOMPARE(coverage(*session, 10, 10), 255);
QCOMPARE(coverage(*session, 75, 75), 255);
// An empty layer selects nothing; nor does a folder.
session->addBlankLayer();
const std::optional<DocumentSelection> before = session->selection();
session->loadLayerSelection(session->activeLayerID().value());
QVERIFY(session->selection() == before);
session->addGroup();
session->loadLayerSelection(session->activeLayerID().value());
QVERIFY(session->selection() == before);
session->loadLayerSelection(QUuid::createUuid());
QVERIFY(session->selection() == before);
}
void SelectionSourceTests::paintedPixelsThatCannotFlattenLoadNothing()
{
// Two painted bases flatten on first use.
const auto session = selectionSession(4000, 3000);
const auto pixels = std::make_shared<const RasterSnapshot>(4000, 3000, solid(2, 2, qRgba(255, 0, 0, 255)), QRectF(0, 0, 2, 2), std::vector<BrushPatch>{});
session->insert(ImportedImage(pixels, QImage(), "Painted"));
const QUuid id = session->activeLayerID().value();
QImage gray(2, 2, QImage::Format_Grayscale8);
gray.fill(Qt::black);
const auto mask = std::make_shared<const RasterSnapshot>(4000, 3000, gray, QRectF(0, 0, 2, 2), std::vector<BrushPatch>{}, true);
rewrite(*session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, ImportedImage(mask, gray, "Mask")); });
const int count = session->history.undoCount();
{
const AddressSpaceLimit limit(8 * 1024 * 1024);
session->loadLayerSelection(id);
session->loadMaskSelection(id);
}
QVERIFY(!session->selection().has_value());
QCOMPARE(session->history.undoCount(), count);
QVERIFY(!pixels->hasMaterializedPixels() && !mask->hasMaterializedPixels());
// With room, the mask loads: its black base, white around.
session->loadMaskSelection(id);
QCOMPARE(bounds(*session), QRectF(0, 0, 2, 2));
}
QTEST_GUILESS_MAIN(SelectionSourceTests)
#include "SelectionSourceTests.moc"