Files
OmaPhoto/tests/MaskSignalTests.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

173 lines
8.8 KiB
C++

#include "SessionFixtures.h"
#include "SessionRecord.h"
#include <QSignalSpy>
#include <QtTest>
// The `changed()` contract for masks in the session.
namespace {
std::unique_ptr<EditorSession> twoLayers(QUuid &lower, QUuid &upper)
{
auto session = std::make_unique<EditorSession>();
session->createDocument(8, 8);
for (QUuid *id : {&lower, &upper}) {
session->insert(ImportedImage(QImage(4, 4, QImage::Format_RGBA8888_Premultiplied), QImage(), "Image"));
*id = session->activeLayerID().value();
}
return session;
}
}
class MaskSignalTests : public QObject {
Q_OBJECT
private slots:
void masksAnnounceAndRefuseInSilence();
void theLastSignalOfEveryMaskChangeSeesWhatItLeaves();
void clippingAnnouncesAndRefusesInSilence();
void theLastSignalOfEveryClippingChangeSeesWhatItLeaves();
};
void MaskSignalTests::masksAnnounceAndRefuseInSilence()
{
QUuid lower, upper;
const std::unique_ptr<EditorSession> session = twoLayers(lower, upper);
QSignalSpy changes(session.get(), &EditorSession::changed);
const auto announced = [&](const std::function<void()> &change) {
const qsizetype before = changes.count();
change();
return changes.count() > before;
};
// No mask yet: nothing to toggle, delete, unlink or copy.
QVERIFY(!announced([&] { session->toggleLayerMask(); }));
QVERIFY(!announced([&] { session->deleteLayerMask(); }));
QVERIFY(!announced([&] { session->toggleMaskLink(upper); }));
QVERIFY(!announced([&] { session->copyMask(upper, lower); }));
QVERIFY(announced([&] { session->addLayerMask(); }));
QVERIFY(!announced([&] { session->addLayerMask(); }));
QVERIFY(announced([&] { session->toggleLayerMask(); }));
QVERIFY(announced([&] { session->toggleMaskLink(upper); }));
QVERIFY(!announced([&] { session->toggleMaskLink(QUuid::createUuid()); }));
QVERIFY(announced([&] { session->selectLayerTarget(upper, false); }));
QVERIFY(announced([&] { session->selectLayerTarget(upper, true); }));
QVERIFY(announced([&] { session->copyMask(upper, lower); }));
QVERIFY(!announced([&] { session->copyMask(upper, upper); }));
QVERIFY(announced([&] { session->deleteLayerMask(); }));
session->setIsImporting(true);
QVERIFY(!announced([&] { session->selectLayerTarget(upper, true); }));
QVERIFY(!announced([&] { session->addLayerMask(); }));
QVERIFY(!announced([&] { session->toggleMaskLink(upper); }));
}
void MaskSignalTests::theLastSignalOfEveryMaskChangeSeesWhatItLeaves()
{
QUuid lower, upper;
const std::unique_ptr<EditorSession> session = twoLayers(lower, upper);
QStringList seen;
connect(session.get(), &EditorSession::changed, this, [&] { seen = described(*session); });
const auto stale = [&](const std::function<void()> &change) {
seen.clear();
change();
const QStringList left = described(*session);
return seen == left ? QString() : seen.join("; ") + " != " + left.join("; ");
};
QCOMPARE(stale([&] { session->addLayerMask(false); }), QString());
QVERIFY(seen.contains("mask target 1") && seen.filter(" mask ").size() == 1);
QCOMPARE(stale([&] { session->toggleLayerMask(); }), QString());
QCOMPARE(stale([&] { session->selectLayerTarget(upper, false); }), QString());
QVERIFY(seen.contains("mask target 0"));
QCOMPARE(stale([&] { session->selectLayerTarget(upper, true); }), QString());
QVERIFY(seen.contains("mask target 1"));
QCOMPARE(stale([&] { session->toggleMaskLink(upper); }), QString());
// The mask alone: begun, moved, committed.
QCOMPARE(stale([&] { session->beginTransform(); }), QString());
QVERIFY(seen.filter(" mask").filter("edit ").size() == 1);
QCOMPARE(stale([&] { session->nudgeLayer(2, 1); }), QString());
QCOMPARE(stale([&] { session->commitTransform(); }), QString());
QVERIFY(seen.contains("names Transform Layer Mask/") && seen.filter("edit ").isEmpty());
QCOMPARE(stale([&] { session->copyMask(upper, lower); }), QString());
QVERIFY(seen.contains("active " + lower.toString()) && seen.contains("mask target 1") && seen.filter(" mask ").size() == 2);
// Another layer, an undo, a project: the target is dropped.
QCOMPARE(stale([&] { session->selectLayer(upper); }), QString());
QVERIFY(seen.contains("mask target 0"));
QCOMPARE(stale([&] { session->selectLayerTarget(upper, true); }), QString());
QCOMPARE(stale([&] { session->deleteLayerMask(); }), QString());
QVERIFY(seen.contains("mask target 0") && seen.filter(" mask ").size() == 1);
QCOMPARE(stale([&] { session->undo(); }), QString());
QVERIFY(seen.filter(" mask ").size() == 2);
QCOMPARE(stale([&] { session->selectLayerTarget(upper, true); }), QString());
const ProjectSnapshot snapshot = session->projectSnapshot().value();
QCOMPARE(stale([&] { session->installProject(snapshot, "masks.comp"); }), QString());
QVERIFY(seen.contains("mask target 0"));
QCOMPARE(stale([&] { session->selectLayerTarget(upper, true); }), QString());
QCOMPARE(stale([&] { session->clearProject(); }), QString());
QVERIFY(seen.contains("mask target 0"));
}
void MaskSignalTests::clippingAnnouncesAndRefusesInSilence()
{
QUuid lower, upper;
const std::unique_ptr<EditorSession> session = twoLayers(lower, upper);
QSignalSpy changes(session.get(), &EditorSession::changed);
const auto announced = [&](const std::function<void()> &change) {
const qsizetype before = changes.count();
change();
return changes.count() > before;
};
// A layer nobody knows: nothing to clip, free or delete.
const QUuid stranger = QUuid::createUuid();
QVERIFY(!announced([&] { session->toggleClippingMask(stranger); }));
QVERIFY(!announced([&] { session->removeLiveMask(stranger); }));
QVERIFY(!announced([&] { session->finishDeletingLayer(stranger, {}); }));
QVERIFY(!announced([&] { session->finishDeletingLayers({}, {}); }));
QVERIFY(!session->canToggleClippingMask(stranger));
// Nothing below to clip to; no link to free.
QVERIFY(!announced([&] { session->toggleClippingMask(lower); }));
QVERIFY(!announced([&] { session->removeLiveMask(upper); }));
QVERIFY(!announced([&] { session->linkMask(upper, upper); }));
QVERIFY(announced([&] { session->toggleClippingMask(upper); }));
// The same link again is no change.
QVERIFY(!announced([&] { session->linkMask(lower, upper); }));
QVERIFY(!announced([&] { session->linkMask(upper, lower); }));
QVERIFY(announced([&] { session->removeLiveMask(upper); }));
QVERIFY(announced([&] { session->linkMask(lower, upper); }));
QVERIFY(announced([&] { session->toggleClippingMask(upper); }));
QVERIFY(announced([&] { session->setBrushError(QString("failed")); }));
QVERIFY(announced([&] { session->setBrushError(std::nullopt); }));
session->setIsImporting(true);
QVERIFY(!announced([&] { session->toggleClippingMask(upper); }));
QVERIFY(!announced([&] { session->linkMask(lower, upper); }));
}
void MaskSignalTests::theLastSignalOfEveryClippingChangeSeesWhatItLeaves()
{
QUuid lower, upper;
const std::unique_ptr<EditorSession> session = twoLayers(lower, upper);
QStringList seen;
connect(session.get(), &EditorSession::changed, this, [&] { seen = described(*session); });
const auto stale = [&](const std::function<void()> &change) {
seen.clear();
change();
const QStringList left = described(*session);
return seen == left ? QString() : seen.join("; ") + " != " + left.join("; ");
};
QCOMPARE(stale([&] { session->toggleClippingMask(upper); }), QString());
QVERIFY(seen.filter("clipped to " + lower.toString()).size() == 1);
QCOMPARE(stale([&] { session->toggleClippingMask(upper); }), QString());
QVERIFY(seen.filter("clipped to " + lower.toString()).isEmpty());
QCOMPARE(stale([&] { QVERIFY(session->linkMask(lower, upper)); }), QString());
QCOMPARE(stale([&] { session->removeLiveMask(upper); }), QString());
QCOMPARE(stale([&] { QVERIFY(session->linkMask(lower, upper)); }), QString());
// A move that breaks the stack frees it at once.
QCOMPARE(stale([&] { QVERIFY(session->placeLayer(lower, std::nullopt)); }), QString());
QVERIFY(seen.filter("clipped to none").size() == 2);
QCOMPARE(stale([&] { session->undo(); }), QString());
// Base gone, baked pixels in, link gone: one state.
const ImportedImage baked(QImage(4, 4, QImage::Format_RGBA8888_Premultiplied), QImage(), "Baked");
QCOMPARE(stale([&] { session->finishDeletingLayer(lower, {{upper, baked}}); }), QString());
QVERIFY(seen.filter(QStringLiteral("pixels %1").arg(baked.identity().cacheKey)).size() == 1);
QCOMPARE(stale([&] { session->setBrushError(QString("failed")); }), QString());
QVERIFY(seen.contains("tool error failed"));
}
QTEST_GUILESS_MAIN(MaskSignalTests)
#include "MaskSignalTests.moc"