Files
OmaPhoto/tests/LiveMaskDeleteTests.cpp
雷电芽衣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

294 lines
12 KiB
C++
Raw Permalink 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 "Rendering/RasterSnapshot.h"
#include "SessionFixtures.h"
#include "SessionRecord.h"
#include <QAbstractButton>
#include <QApplication>
#include <QMessageBox>
#include <QSemaphore>
#include <QThreadPool>
#include <QTimer>
#include <QtTest>
// Deleting a base others clip to: the alert's three answers.
namespace {
struct Asked {
int times = 0;
QMessageBox::Icon icon = QMessageBox::NoIcon;
QString text;
QString details;
QStringList buttons;
};
// Clicks `answer` on the alert once it is up.
void answer(Asked &asked, const QString &reply)
{
auto *timer = new QTimer(qApp);
QObject::connect(timer, &QTimer::timeout, qApp, [&asked, reply, timer] {
auto *alert = qobject_cast<QMessageBox *>(QApplication::activeModalWidget());
if (!alert)
return;
timer->deleteLater();
asked.times += 1;
asked.icon = alert->icon();
asked.text = alert->text();
asked.details = alert->informativeText();
for (const QAbstractButton *button : alert->buttons())
asked.buttons << button->text();
for (QAbstractButton *button : alert->buttons()) {
if (button->text() == reply)
button->click();
}
});
timer->start(0);
}
QImage solid(QColor colour, int alpha)
{
QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied);
colour.setAlpha(alpha);
image.fill(colour);
return image;
}
// Blue, opaque, clipped to a half-clear red base.
std::unique_ptr<EditorSession> clipped(QUuid &base, QUuid &top)
{
auto session = std::make_unique<EditorSession>();
session->createDocument(2, 2);
session->insert(ImportedImage(solid(Qt::red, 128), QImage(), "Base"));
base = session->activeLayerID().value();
session->insert(ImportedImage(solid(Qt::blue, 255), QImage(), "Top"));
top = session->activeLayerID().value();
session->toggleClippingMask(top);
return session;
}
int alphaOf(const EditorSession &session, QUuid id)
{
return layerWith(session, id).asset.value().image().pixelColor(0, 0).alpha();
}
}
class LiveMaskDeleteTests : public QObject {
Q_OBJECT
private slots:
void aLayerNothingDependsOnGoesUnasked();
void removingLinksDeletesAndRevealsThePixels();
void cancelChangesNothing();
void bakingKeepsTheLookInTheDependentsPixels();
void theBakeQueuesForAWorkerAndTheCallerGoesOn();
void severalLayersAreAskedAboutOnce();
void aBakeThatFailsDeletesNothingAndSaysWhy();
};
void LiveMaskDeleteTests::aLayerNothingDependsOnGoesUnasked()
{
QUuid base, top;
const std::unique_ptr<EditorSession> session = clipped(base, top);
// The clipped layer supplies no mask: no question.
QVERIFY(!session->deleteWithLiveMaskChoice({top}));
QCOMPARE(int(session->document().value().layers.size()), 2);
session->deleteLayer(top);
QCOMPARE(int(session->document().value().layers.size()), 1);
QCOMPARE(session->history.undoName(), QString("Delete Layer"));
// Without a canvas nothing depends on anything.
EditorSession empty;
QVERIFY(!empty.deleteWithLiveMaskChoice({top}));
}
void LiveMaskDeleteTests::removingLinksDeletesAndRevealsThePixels()
{
QUuid base, top;
const std::unique_ptr<EditorSession> session = clipped(base, top);
const ImageIdentity pixels = layerWith(*session, top).asset.value().identity();
const int count = session->history.undoCount();
Asked asked;
answer(asked, "Remove Links and Delete");
session->deleteLayer(base);
QCOMPARE(asked.times, 1);
QCOMPARE(asked.icon, QMessageBox::Warning);
QCOMPARE(asked.text, QString("This layer supplies a live mask"));
QCOMPARE(asked.details, QString("Bake keeps the current masked appearance in the dependent layers’ pixels. "
"Remove Links reveals their pixels. You can undo either choice."));
QCOMPARE(asked.buttons, (QStringList{"Bake and Delete", "Cancel", "Remove Links and Delete"}));
QCOMPARE(int(session->document().value().layers.size()), 1);
QCOMPARE(layerWith(*session, top).maskSourceID, std::nullopt);
QVERIFY(layerWith(*session, top).asset.value().identity() == pixels);
QVERIFY(!session->isProjectBusy());
QCOMPARE(session->history.undoCount(), count + 1);
session->undo();
QCOMPARE(layerWith(*session, top).maskSourceID, std::optional(base));
}
void LiveMaskDeleteTests::cancelChangesNothing()
{
QUuid base, top;
const std::unique_ptr<EditorSession> session = clipped(base, top);
const std::optional<CanvasDocument> before = session->document();
const int count = session->history.undoCount();
Asked asked;
answer(asked, "Cancel");
session->deleteLayer(base);
QCOMPARE(asked.times, 1);
QCOMPARE(session->document(), before);
QCOMPARE(session->history.undoCount(), count);
QVERIFY(!session->isProjectBusy());
}
void LiveMaskDeleteTests::bakingKeepsTheLookInTheDependentsPixels()
{
QUuid base, top;
const std::unique_ptr<EditorSession> session = clipped(base, top);
const ImageIdentity pixels = layerWith(*session, top).asset.value().identity();
QCOMPARE(alphaOf(*session, top), 255);
const int count = session->history.undoCount();
Asked asked;
QStringList seen;
bool freeTooSoon = false;
connect(session.get(), &EditorSession::changed, this, [&] {
seen = described(*session);
// Free again only once the layers are gone.
freeTooSoon = freeTooSoon || (asked.times == 1 && !session->isProjectBusy() && session->document().value().layers.size() == 2);
});
answer(asked, "Bake and Delete");
QTest::ignoreMessage(QtInfoMsg, "baking 1 live masks before a deletion");
session->deleteLayer(base);
QCOMPARE(asked.times, 1);
QCOMPARE(seen, described(*session));
QVERIFY(seen.contains("flags 1000"));
// The bake runs on a worker; the project is busy.
QVERIFY(session->isProjectBusy());
QCOMPARE(int(session->document().value().layers.size()), 2);
QVERIFY(QTest::qWaitFor([&] { return !session->isProjectBusy(); }, 10'000));
// The last signal sees the deletion and a free project.
QCOMPARE(seen, described(*session));
QVERIFY(!freeTooSoon);
QVERIFY(seen.contains("flags 0000"));
QCOMPARE(int(session->document().value().layers.size()), 1);
QCOMPARE(layerWith(*session, top).maskSourceID, std::nullopt);
QVERIFY(layerWith(*session, top).asset.value().identity() != pixels);
QCOMPARE(layerWith(*session, top).asset.value().name, QString("Top"));
// Half the base's alpha is in the pixels now.
QVERIFY2(std::abs(alphaOf(*session, top) - 128) <= 1, qPrintable(QString::number(alphaOf(*session, top))));
QCOMPARE(layerWith(*session, top).asset.value().image().pixelColor(0, 0).blue(), 255);
QCOMPARE(session->brushError(), std::nullopt);
QCOMPARE(session->history.undoCount(), count + 1);
QCOMPARE(session->history.undoName(), QString("Delete Layer"));
// Undo brings back the base, the link, the old pixels.
session->undo();
QCOMPARE(int(session->document().value().layers.size()), 2);
QCOMPARE(layerWith(*session, top).maskSourceID, std::optional(base));
QVERIFY(layerWith(*session, top).asset.value().identity() == pixels);
}
void LiveMaskDeleteTests::theBakeQueuesForAWorkerAndTheCallerGoesOn()
{
// A painted base: its pixels flatten on first use.
const auto raster = std::make_shared<const RasterSnapshot>(2, 2, solid(Qt::red, 128), QRectF(0, 0, 2, 2), std::vector<BrushPatch>{});
EditorSession session;
session.createDocument(2, 2);
session.insert(ImportedImage(raster, QImage(), "Base"));
const QUuid base = session.activeLayerID().value();
session.insert(ImportedImage(solid(Qt::blue, 255), QImage(), "Top"));
const QUuid top = session.activeLayerID().value();
session.toggleClippingMask(top);
// Every worker is held: the bake has to queue.
QThreadPool *pool = QThreadPool::globalInstance();
const int workers = pool->maxThreadCount();
QSemaphore held, gate;
// A failed check must not strand the held workers.
const auto letGo = qScopeGuard([&] {
gate.release(workers);
pool->waitForDone();
});
for (int index = 0; index < workers; ++index) {
pool->start([&] {
held.release();
gate.acquire();
});
}
QVERIFY(held.tryAcquire(workers, 10'000));
Asked asked;
answer(asked, "Bake and Delete");
QTest::ignoreMessage(QtInfoMsg, "baking 1 live masks before a deletion");
session.deleteLayer(base);
// Back at once: busy, nothing flattened, nothing deleted.
QCOMPARE(asked.times, 1);
QVERIFY(session.isProjectBusy());
QVERIFY(!raster->hasMaterializedPixels());
gate.release(workers);
// No event runs in this wait: a worker flattens.
QElapsedTimer clock;
clock.start();
while (!raster->hasMaterializedPixels() && clock.elapsed() < 10'000)
QTest::qSleep(5);
QVERIFY(raster->hasMaterializedPixels());
QVERIFY(session.isProjectBusy());
QCOMPARE(int(session.document().value().layers.size()), 2);
QVERIFY(QTest::qWaitFor([&] { return !session.isProjectBusy(); }, 10'000));
QCOMPARE(int(session.document().value().layers.size()), 1);
QVERIFY2(std::abs(alphaOf(session, top) - 128) <= 1, qPrintable(QString::number(alphaOf(session, top))));
}
void LiveMaskDeleteTests::severalLayersAreAskedAboutOnce()
{
QUuid base, top;
const std::unique_ptr<EditorSession> session = clipped(base, top);
session->selectLayer(std::nullopt);
session->insert(ImportedImage(solid(Qt::green, 255), QImage(), "Other"));
const QUuid other = session->activeLayerID().value();
session->selectLayers({base, other}, other);
Asked asked;
answer(asked, "Remove Links and Delete");
session->deleteSelectedLayers();
QCOMPARE(asked.times, 1);
QCOMPARE(asked.text, QString("These layers supply live masks"));
QCOMPARE(int(session->document().value().layers.size()), 1);
QCOMPARE(session->history.undoName(), QString("Delete Layers"));
QCOMPARE(layerWith(*session, top).maskSourceID, std::nullopt);
// A folder that holds the base counts as supplying it.
session->undo();
session->selectLayers({base}, base);
session->groupSelectedLayers();
const QUuid folder = session->activeLayerID().value();
QVERIFY(session->linkMask(base, top));
Asked again;
answer(again, "Cancel");
session->deleteLayer(folder);
QCOMPARE(again.times, 1);
QCOMPARE(again.text, QString("This layer supplies a live mask"));
QCOMPARE(int(session->document().value().layers.size()), 4);
// A folder holding base and dependent leaves nobody behind.
QVERIFY(session->placeLayer(top, folder));
QVERIFY(session->linkMask(base, top));
QVERIFY(!session->deleteWithLiveMaskChoice({folder}));
session->deleteLayer(folder);
QCOMPARE(int(session->document().value().layers.size()), 1);
}
void LiveMaskDeleteTests::aBakeThatFailsDeletesNothingAndSaysWhy()
{
EditorSession session;
session.createDocument(2, 2);
session.insert(ImportedImage(solid(Qt::red, 128), QImage(), "Base"));
const QUuid base = session.activeLayerID().value();
// An asset without pixels: no surface can hold it.
session.insert(ImportedImage(QImage(), QImage(), "Void"));
const QUuid hollow = session.activeLayerID().value();
QVERIFY(session.linkMask(base, hollow));
const std::optional<CanvasDocument> before = session.document();
Asked asked;
answer(asked, "Bake and Delete");
QTest::ignoreMessage(QtInfoMsg, "baking 1 live masks before a deletion");
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^cannot bake the live masks: .*"));
session.deleteLayer(base);
QVERIFY(QTest::qWaitFor([&] { return !session.isProjectBusy(); }, 30'000));
QCOMPARE(session.document(), before);
QCOMPARE(session.brushError(), std::optional(QString("The canvas could not be rendered. Try a smaller canvas.")));
session.setBrushError(std::nullopt);
QCOMPARE(session.brushError(), std::nullopt);
}
QTEST_MAIN(LiveMaskDeleteTests)
#include "LiveMaskDeleteTests.moc"