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

233 lines
11 KiB
C++

#include "Document/LiveLayerMask.h"
#include "Document/PixelAdjust.h"
#include "SessionFixtures.h"
#include <QtTest>
// The baker: a live mask pressed into a layer's pixels.
class LiveMaskBakeTests : public QObject {
Q_OBJECT
private slots:
void aBakeMapsTheSourceIntoTheTargetsOwnPixels();
void aBakeKeepsTheSourcesOwnMaskInAnyShape();
void aStretchedSourceBakesThroughItsOwnGrid();
void aSourceWithoutPixelsHidesTheTarget();
void aSnapshotThatRepeatsAnIdIsRefused();
void thumbnailsShrinkToNinetySixAndNeverGrow();
};
void LiveMaskBakeTests::aBakeMapsTheSourceIntoTheTargetsOwnPixels()
{
// The source at the origin; the target one pixel right.
QImage alpha(2, 2, QImage::Format_RGBA8888_Premultiplied);
const int alphas[] = {255, 0, 128, 255};
for (int index = 0; index < 4; ++index)
alpha.setPixelColor(index % 2, index / 2, QColor(255, 0, 0, alphas[index]));
QImage opaque(2, 2, QImage::Format_RGBA8888_Premultiplied);
opaque.fill(Qt::blue);
EditorSession session;
session.createDocument(4, 2);
session.insert(ImportedImage(alpha, QImage(), "Source"), QPointF(1, 1));
const QUuid source = session.activeLayerID().value();
session.insert(ImportedImage(opaque, QImage(), "Target"), QPointF(2, 1));
const QUuid target = session.activeLayerID().value();
rewrite(session, [&](ProjectSnapshot &snapshot) {
for (ProjectLayerRecord &layer : snapshot.manifest.layers)
layer.transform.sampling = LayerSampling::nearest;
// The target's own opacity and mask stay out.
record(snapshot, target).opacity = 0.5;
record(snapshot, source).opacity = 0.5;
QImage gray(2, 2, QImage::Format_Grayscale8);
gray.fill(128);
setMask(snapshot, target, LayerMask::assetFrom(gray));
});
QVERIFY(session.linkMask(source, target));
const ProjectSnapshot snapshot = session.projectSnapshot().value();
const ImportedImage baked = LiveMaskBaker::bake(snapshot, target).value();
QCOMPARE(baked.size(), QSize(2, 2));
QCOMPARE(baked.name, QString("Target"));
QCOMPARE(baked.thumbnail.size(), QSize(2, 2));
// Its left column meets the source's right: 0 and 128.
const QImage pixels = baked.image();
QCOMPARE(pixels.pixelColor(0, 0).alpha(), 0);
QVERIFY2(std::abs(pixels.pixelColor(0, 1).alpha() - 128) <= 1, qPrintable(QString::number(pixels.pixelColor(0, 1).alpha())));
QCOMPARE(pixels.pixelColor(0, 1).blue(), 255);
QCOMPARE(pixels.pixelColor(1, 0).alpha(), 0);
QCOMPARE(pixels.pixelColor(1, 1).alpha(), 0);
// A source clipped in turn: both alphas, each in place.
QImage half(2, 2, QImage::Format_RGBA8888_Premultiplied);
half.fill(QColor(0, 255, 0, 128));
session.selectLayer(std::nullopt);
session.insert(ImportedImage(half, QImage(), "Second"), QPointF(1, 1));
const QUuid secondSource = session.activeLayerID().value();
QVERIFY(session.linkMask(secondSource, source));
QTest::failOnWarning(QRegularExpression(".*"));
const QImage chained = LiveMaskBaker::bake(session.projectSnapshot().value(), target).value().image();
QCOMPARE(chained.pixelColor(0, 0).alpha(), 0);
QVERIFY2(std::abs(chained.pixelColor(0, 1).alpha() - 64) <= 1, qPrintable(QString::number(chained.pixelColor(0, 1).alpha())));
QCOMPARE(chained.pixelColor(1, 1).alpha(), 0);
// No pixels, or no such layer: nothing to bake.
QVERIFY(!LiveMaskBaker::bake(snapshot, QUuid::createUuid()).has_value());
ProjectSnapshot bare = snapshot;
bare.images.erase(target);
QVERIFY(!LiveMaskBaker::bake(bare, target).has_value());
}
void LiveMaskBakeTests::aBakeKeepsTheSourcesOwnMaskInAnyShape()
{
// Three by two: a square would hide swapped sides.
QImage red(3, 2, QImage::Format_RGBA8888_Premultiplied);
red.fill(Qt::red);
QImage blue(3, 2, QImage::Format_RGBA8888_Premultiplied);
blue.fill(Qt::blue);
QImage gray(3, 2, QImage::Format_Grayscale8);
const uchar values[] = {255, 0, 128, 0, 255, 64};
for (int index = 0; index < 6; ++index)
gray.scanLine(index / 3)[index % 3] = values[index];
EditorSession session;
session.createDocument(3, 2);
session.insert(ImportedImage(red, QImage(), "Source"));
const QUuid source = session.activeLayerID().value();
session.insert(ImportedImage(blue, QImage(), "Target"));
const QUuid target = session.activeLayerID().value();
rewrite(session, [&](ProjectSnapshot &snapshot) {
for (ProjectLayerRecord &layer : snapshot.manifest.layers)
layer.transform.sampling = LayerSampling::nearest;
setMask(snapshot, source, LayerMask::assetFrom(gray));
});
QVERIFY(session.linkMask(source, target));
const ImportedImage baked = LiveMaskBaker::bake(session.projectSnapshot().value(), target).value();
QCOMPARE(baked.size(), QSize(3, 2));
QCOMPARE(baked.thumbnail.size(), QSize(3, 2));
// The source's own mask hides what it hides.
const QImage pixels = baked.image();
for (int index = 0; index < 6; ++index) {
const QColor pixel = pixels.pixelColor(index % 3, index / 3);
QVERIFY2(std::abs(pixel.alpha() - values[index]) <= 1, qPrintable(QString("%1: %2").arg(index).arg(pixel.alpha())));
if (values[index] == 255)
QCOMPARE(pixel, QColor(Qt::blue));
}
// Moved a pixel right, the mask fills the source's grid.
rewrite(session, [&](ProjectSnapshot &snapshot) { record(snapshot, source).maskPlacement = LayerTransform{.origin = {1, 0}, .size = {3, 2}}; });
const QImage moved = LiveMaskBaker::bake(session.projectSnapshot().value(), target).value().image();
const int shifted[] = {0, 255, 0, 0, 0, 255};
for (int index = 0; index < 6; ++index) {
const int alpha = moved.pixelColor(index % 3, index / 3).alpha();
QVERIFY2(std::abs(alpha - shifted[index]) <= 1, qPrintable(QString("%1: %2").arg(index).arg(alpha)));
}
}
void LiveMaskBakeTests::aStretchedSourceBakesThroughItsOwnGrid()
{
// Three by two pixels stretched over six by four.
QImage red(3, 2, QImage::Format_RGBA8888_Premultiplied);
red.fill(Qt::red);
QImage blue(6, 4, QImage::Format_RGBA8888_Premultiplied);
blue.fill(Qt::blue);
QImage gray(3, 2, QImage::Format_Grayscale8);
const uchar values[] = {255, 0, 128, 0, 255, 64};
for (int index = 0; index < 6; ++index)
gray.scanLine(index / 3)[index % 3] = values[index];
EditorSession session;
session.createDocument(6, 4);
session.insert(ImportedImage(red, QImage(), "Source"));
const QUuid source = session.activeLayerID().value();
session.insert(ImportedImage(blue, QImage(), "Target"));
const QUuid target = session.activeLayerID().value();
rewrite(session, [&](ProjectSnapshot &snapshot) {
for (ProjectLayerRecord &layer : snapshot.manifest.layers)
layer.transform.sampling = LayerSampling::nearest;
record(snapshot, source).transform.origin = {0, 0};
record(snapshot, source).transform.size = {6, 4};
// The mask sits one source pixel to the right.
setMask(snapshot, source, LayerMask::assetFrom(gray));
record(snapshot, source).maskPlacement = LayerTransform{.origin = {2, 0}, .size = {6, 4}};
});
QVERIFY(session.linkMask(source, target));
ProjectSnapshot snapshot = session.projectSnapshot().value();
// An older project states no opacity: that means one.
record(snapshot, source).opacity = std::nullopt;
const ImportedImage baked = LiveMaskBaker::bake(snapshot, target).value();
QCOMPARE(baked.size(), QSize(6, 4));
const QImage pixels = baked.image();
const int grid[] = {0, 255, 0, 0, 0, 255};
for (int y = 0; y < 4; ++y) {
for (int x = 0; x < 6; ++x) {
const int alpha = pixels.pixelColor(x, y).alpha();
QVERIFY2(alpha == grid[y / 2 * 3 + x / 2], qPrintable(QString("%1,%2: %3").arg(x).arg(y).arg(alpha)));
}
}
}
void LiveMaskBakeTests::aSourceWithoutPixelsHidesTheTarget()
{
EditorSession session;
session.createDocument(2, 2);
session.addBlankLayer();
const QUuid blank = session.activeLayerID().value();
QImage blue(2, 2, QImage::Format_RGBA8888_Premultiplied);
blue.fill(Qt::blue);
session.insert(ImportedImage(blue, QImage(), "Target"));
const QUuid target = session.activeLayerID().value();
QVERIFY(session.linkMask(blank, target));
QTest::failOnWarning(QRegularExpression(".*"));
// A blank source covers nothing: all of the target goes.
ProjectSnapshot snapshot = session.projectSnapshot().value();
QVERIFY(!snapshot.images.contains(blank));
const ImportedImage baked = LiveMaskBaker::bake(snapshot, target).value();
QCOMPARE(baked.size(), QSize(2, 2));
for (int index = 0; index < 4; ++index)
QCOMPARE(baked.image().pixelColor(index % 2, index / 2).alpha(), 0);
// So does a link to a layer the snapshot lacks,
const QUuid stranger = QUuid::createUuid();
record(snapshot, target).maskSourceID = stranger;
const QImage orphan = LiveMaskBaker::bake(snapshot, target).value().image();
for (int index = 0; index < 4; ++index)
QCOMPARE(orphan.pixelColor(index % 2, index / 2).alpha(), 0);
// even when stray pixels carry that layer's id.
snapshot.images.insert({stranger, ImportedImage(blue, QImage(), "Stray")});
const QImage stray = LiveMaskBaker::bake(snapshot, target).value().image();
for (int index = 0; index < 4; ++index)
QCOMPARE(stray.pixelColor(index % 2, index / 2).alpha(), 0);
// Pixels without a record are no layer to bake.
QVERIFY(!LiveMaskBaker::bake(snapshot, stranger).has_value());
}
void LiveMaskBakeTests::aSnapshotThatRepeatsAnIdIsRefused()
{
EditorSession session;
session.createDocument(2, 2);
session.insert(ImportedImage(QImage(2, 2, QImage::Format_RGBA8888_Premultiplied), QImage(), "Only"));
const QUuid only = session.activeLayerID().value();
ProjectSnapshot twice = session.projectSnapshot().value();
twice.manifest.layers.push_back(twice.manifest.layers.front());
// Swift's dictionary of unique keys traps here.
QVERIFY_EXCEPTION_THROWN(LiveMaskBaker::bake(twice, only), std::logic_error);
// Nothing to bake is settled first, as in Swift.
QVERIFY(!LiveMaskBaker::bake(twice, QUuid::createUuid()).has_value());
ProjectSnapshot bare = twice;
bare.images.erase(only);
QVERIFY(!LiveMaskBaker::bake(bare, only).has_value());
}
void LiveMaskBakeTests::thumbnailsShrinkToNinetySixAndNeverGrow()
{
const auto size = [](int width, int height) { return PixelAdjust::thumbnail(QImage(width, height, QImage::Format_RGBA8888_Premultiplied)).size(); };
QCOMPARE(size(960, 480), QSize(96, 48));
QCOMPARE(size(480, 960), QSize(48, 96));
// Truncated, never under one, never enlarged.
QCOMPARE(size(1000, 15), QSize(96, 1));
QCOMPARE(size(4000, 10), QSize(96, 1));
QCOMPARE(size(97, 50), QSize(96, 49));
QCOMPARE(size(100, 58), QSize(96, 55));
QCOMPARE(size(96, 96), QSize(96, 96));
QCOMPARE(size(8, 4), QSize(8, 4));
QImage red(200, 100, QImage::Format_RGBA8888_Premultiplied);
red.fill(Qt::red);
const QImage small = PixelAdjust::thumbnail(red);
QCOMPARE(small.format(), QImage::Format_RGBA8888_Premultiplied);
QCOMPARE(small.pixelColor(95, 47), QColor(Qt::red));
}
QTEST_GUILESS_MAIN(LiveMaskBakeTests)
#include "LiveMaskBakeTests.moc"