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

410 lines
19 KiB
C++

#include "Document/EditorSession.h"
#include <QtTest>
// Transform edits in the session beyond Swift's own cases.
namespace {
QUuid insertImage(EditorSession &session, int width, int height, QPointF center)
{
session.insert(ImportedImage(QImage(width, height, QImage::Format_RGBA8888_Premultiplied), QImage(), "Image"), center);
return session.activeLayerID().value();
}
ImageLayer layerWith(const EditorSession &session, QUuid id)
{
for (const ImageLayer &layer : session.document().value().layers) {
if (layer.id == id)
return layer;
}
throw std::runtime_error("no such layer");
}
QSet<QUuid> ids(const std::vector<ImageLayer> &layers)
{
QSet<QUuid> result;
for (const ImageLayer &layer : layers)
result.insert(layer.id);
return result;
}
}
class TransformSessionTests : public QObject {
Q_OBJECT
private slots:
void severalLayersMoveInOneBox();
void aFolderCarriesItsVisiblePixels();
void aFolderReachesSixtyThreeLevelsDown();
void aGroupMoveKeepsEachTurnFlipAndSampling();
void aBoxSqueezedTooFarLeavesThatLayer();
void theBoxHoldsEveryCornerOfATurnedLayer();
void aUnitLayerStillGetsAWholeBox();
void nudgingIsOneStepOrPartOfAnOpenEdit();
void anotherLayerToolOrCanvasCommits();
void anOpenTransformClosesTheGates();
void handlesFollowTheDraftOrTheBox();
};
void TransformSessionTests::severalLayersMoveInOneBox()
{
EditorSession session;
session.createDocument(400, 300);
// The lower right image comes first in the list.
const QUuid right = insertImage(session, 20, 60, {300, 200});
const QUuid left = insertImage(session, 40, 20, {50, 50});
const QUuid apart = insertImage(session, 10, 10, {200, 20});
QVERIFY(!session.transformsAsGroup());
QVERIFY(session.groupTransformMembers().empty());
QVERIFY(!session.groupTransformBox().has_value());
session.selectLayers({left, right}, left);
QVERIFY(session.transformsAsGroup() && session.canTransform());
QCOMPARE(ids(session.groupTransformMembers()), (QSet<QUuid>{left, right}));
// From the left image's corner to the right one's.
const LayerTransform box{.origin = {30, 40}, .size = {280, 190}};
QCOMPARE(session.groupTransformBox().value(), box);
QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(280, 190)));
session.selectTool(NavigationTool::hand);
session.selectLayers({left, right}, left);
session.beginTransform();
QCOMPARE(session.tool(), NavigationTool::move);
const TransformEdit edit = session.transformEdit().value();
QCOMPARE(edit.layerID, left);
QCOMPARE(edit.draft, box);
QCOMPARE(edit.group.value().box, box);
QCOMPARE(edit.group.value().originals.value(left), layerWith(session, left).transform);
QCOMPARE(edit.group.value().originals.value(right), layerWith(session, right).transform);
QCOMPARE(int(edit.group.value().originals.size()), 2);
LayerTransform moved = box;
moved.origin += QPointF(10, -5);
const int count = session.history.undoCount();
session.previewTransform(moved);
// The preview carries both; the document waits for the commit.
QCOMPARE(session.displayedTransform(layerWith(session, left)).origin, QPointF(40, 35));
QCOMPARE(session.displayedTransform(layerWith(session, right)).origin, QPointF(300, 165));
QCOMPARE(session.pendingTransform(layerWith(session, apart)), std::nullopt);
QCOMPARE(session.displayedTransform(layerWith(session, apart)), layerWith(session, apart).transform);
QCOMPARE(layerWith(session, left).transform.origin, QPointF(30, 40));
// The size shown is the box as it began.
QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(280, 190)));
session.commitTransform();
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(layerWith(session, left).transform.origin, QPointF(40, 35));
QCOMPARE(layerWith(session, right).transform.origin, QPointF(300, 165));
QCOMPARE(layerWith(session, apart).transform.origin, QPointF(195, 15));
QCOMPARE(session.history.undoCount(), count + 1);
QCOMPARE(session.history.undoName(), QString("Transform Layers"));
session.undo();
QCOMPARE(layerWith(session, left).transform.origin, QPointF(30, 40));
QCOMPARE(layerWith(session, right).transform.origin, QPointF(290, 170));
}
void TransformSessionTests::aFolderCarriesItsVisiblePixels()
{
EditorSession session;
session.createDocument(400, 300);
session.addGroup();
const QUuid folder = session.activeLayerID().value();
const QUuid shown = insertImage(session, 40, 20, {50, 50});
session.selectLayer(folder);
session.addGroup();
const QUuid nested = insertImage(session, 20, 20, {100, 100});
session.selectLayer(folder);
const QUuid hidden = insertImage(session, 30, 30, {300, 200});
session.toggleLayerVisibility(hidden);
session.selectLayer(folder);
session.addBlankLayer();
session.selectLayer(std::nullopt);
const QUuid outside = insertImage(session, 10, 10, {350, 250});
session.selectLayer(folder);
// One selected folder: its shown pixels, however deep.
QVERIFY(session.transformsAsGroup() && session.canTransform());
QCOMPARE(ids(session.groupTransformMembers()), (QSet<QUuid>{shown, nested}));
QCOMPARE(session.groupTransformBox().value(), (LayerTransform{.origin = {30, 40}, .size = {80, 70}}));
// The handles sit on the box until an edit begins.
QCOMPARE(session.editedTransform(layerWith(session, folder)), session.groupTransformBox().value());
QCOMPARE(session.editedTransform(layerWith(session, outside)), layerWith(session, outside).transform);
// Hidden, the folder shows nothing to move.
session.toggleLayerVisibility(folder);
QVERIFY(session.groupTransformMembers().empty());
QVERIFY(!session.canTransform());
QCOMPARE(session.editedTransform(layerWith(session, folder)), layerWith(session, folder).transform);
session.beginTransform();
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(session.transformPixelSize(), std::nullopt);
// Inside a hidden folder a layer cannot transform either.
session.selectLayer(shown);
QVERIFY(!session.transformsAsGroup() && !session.canTransform());
session.toggleLayerVisibility(folder);
QVERIFY(session.canTransform());
}
void TransformSessionTests::aFolderReachesSixtyThreeLevelsDown()
{
EditorSession session;
session.createDocument(64, 64);
// Each new folder goes inside the last: 64 deep.
std::vector<QUuid> folders;
for (int depth = 0; depth < 64; ++depth) {
session.addGroup();
folders.push_back(session.activeLayerID().value());
}
const QUuid image = insertImage(session, 8, 8, {32, 32});
QCOMPARE(layerWith(session, image).parentID, std::optional(folders.back()));
QVERIFY(session.document().value().effectiveVisibleIDs().contains(image));
// The walk takes 64 steps, the layer itself among them.
session.selectLayer(folders[1]);
QCOMPARE(ids(session.groupTransformMembers()), QSet<QUuid>{image});
QVERIFY(session.canTransform());
session.selectLayer(folders[0]);
QVERIFY(session.groupTransformMembers().empty());
QVERIFY(!session.canTransform());
}
void TransformSessionTests::aGroupMoveKeepsEachTurnFlipAndSampling()
{
EditorSession session;
session.createDocument(400, 300);
// Opposite flips, different turns and samplings.
const auto placed = [&](QPointF center, double rotation, bool flipX, LayerSampling sampling) {
const QUuid id = insertImage(session, 40, 20, center);
session.beginTransform();
LayerTransform value = session.transformEdit().value().draft;
value.rotation = rotation;
value.flipX = flipX;
value.flipY = !flipX;
value.sampling = sampling;
session.previewTransform(value);
session.commitTransform();
return id;
};
const QUuid first = placed({100, 100}, 30, true, LayerSampling::nearest);
const QUuid second = placed({250, 180}, -75, false, LayerSampling::smooth);
const LayerTransform firstBefore = layerWith(session, first).transform, secondBefore = layerWith(session, second).transform;
QCOMPARE(firstBefore.sampling, LayerSampling::nearest);
QVERIFY(firstBefore.flipX && !firstBefore.flipY && !secondBefore.flipX && secondBefore.flipY);
session.selectLayers({first, second}, second);
session.beginTransform();
QCOMPARE(session.transformEdit().value().group.value().originals.value(first), firstBefore);
QCOMPARE(session.transformEdit().value().group.value().originals.value(second), secondBefore);
session.nudgeLayer(5, 3);
// The preview and the commit move origins and nothing else.
LayerTransform firstAfter = firstBefore, secondAfter = secondBefore;
firstAfter.origin += QPointF(5, 3);
secondAfter.origin += QPointF(5, 3);
QCOMPARE(session.displayedTransform(layerWith(session, first)), firstAfter);
QCOMPARE(session.displayedTransform(layerWith(session, second)), secondAfter);
session.commitTransform();
QCOMPARE(layerWith(session, first).transform, firstAfter);
QCOMPARE(layerWith(session, second).transform, secondAfter);
// One layer's edit starts from all of its transform too.
session.selectLayer(first);
const int count = session.history.undoCount();
session.beginTransform();
QCOMPARE(session.transformEdit().value().draft, firstAfter);
QVERIFY(!session.transformEdit().value().group.has_value());
session.commitTransform();
QCOMPARE(session.history.undoCount(), count);
}
void TransformSessionTests::aBoxSqueezedTooFarLeavesThatLayer()
{
EditorSession session;
session.createDocument(400, 300);
const QUuid wide = insertImage(session, 200, 100, {100, 50});
const QUuid thin = insertImage(session, 2, 100, {300, 50});
session.selectLayers({wide, thin}, wide);
session.beginTransform();
LayerTransform squeezed = session.transformEdit().value().draft;
QCOMPARE(squeezed, (LayerTransform{.origin = {0, 0}, .size = {301, 100}}));
// Quartered, the thin layer would be half a pixel.
squeezed.size.setWidth(squeezed.size.width() / 4);
session.previewTransform(squeezed);
// The pixels shown stay the box's as it began.
QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(301, 100)));
session.commitTransform();
QCOMPARE(layerWith(session, wide).transform.size, QSizeF(50, 100));
QCOMPARE(layerWith(session, thin).transform, (LayerTransform{.origin = {299, 0}, .size = {2, 100}}));
}
void TransformSessionTests::theBoxHoldsEveryCornerOfATurnedLayer()
{
EditorSession session;
session.createDocument(200, 200);
session.addGroup();
const QUuid folder = session.activeLayerID().value();
insertImage(session, 40, 20, {100, 100});
session.beginTransform();
LayerTransform turned = session.transformEdit().value().draft;
turned.rotation = 90;
session.previewTransform(turned);
session.commitTransform();
session.selectLayer(folder);
// A quarter turn: 20 by 40 about (100, 100).
const LayerTransform box = session.groupTransformBox().value();
QVERIFY2(std::abs(box.origin.x() - 90) < 1e-9 && std::abs(box.origin.y() - 80) < 1e-9, qPrintable(QString::number(box.origin.x())));
QVERIFY2(std::abs(box.size.width() - 20) < 1e-9 && std::abs(box.size.height() - 40) < 1e-9, qPrintable(QString::number(box.size.width())));
QCOMPARE(box.rotation, 0.0);
}
void TransformSessionTests::aUnitLayerStillGetsAWholeBox()
{
EditorSession session;
session.createDocument(64, 64);
session.addGroup();
const QUuid folder = session.activeLayerID().value();
const QUuid dot = insertImage(session, 1, 1, {10, 10});
session.beginTransform();
LayerTransform off = session.transformEdit().value().draft;
off.origin = {1.3, 1.3};
session.previewTransform(off);
session.commitTransform();
// In doubles these corners lie under one pixel apart.
QVERIFY((1.3 + 0.5 + 0.5) - (1.3 + 0.5 - 0.5) < 1.0);
session.selectLayer(folder);
QCOMPARE(session.groupTransformBox().value().size, QSizeF(1, 1));
session.beginTransform();
QVERIFY(session.transformEdit().value().draft.isValid());
QCOMPARE(layerWith(session, dot).transform.origin, QPointF(1.3, 1.3));
}
void TransformSessionTests::nudgingIsOneStepOrPartOfAnOpenEdit()
{
EditorSession session;
session.createDocument(100, 100);
session.nudgeLayer(1, 0);
QVERIFY(!session.transformEdit().has_value());
const QUuid image = insertImage(session, 20, 10, {50, 50});
const int count = session.history.undoCount();
session.nudgeLayer(3, -2);
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(layerWith(session, image).transform.origin, QPointF(43, 43));
QCOMPARE(session.history.undoCount(), count + 1);
QCOMPARE(session.history.undoName(), QString("Transform Layer"));
// Inside an open edit a nudge moves the draft alone.
session.beginTransform();
session.nudgeLayer(1, 0);
session.nudgeLayer(0, 4);
QCOMPARE(session.transformEdit().value().draft.origin, QPointF(44, 47));
QCOMPARE(layerWith(session, image).transform.origin, QPointF(43, 43));
QCOMPARE(session.history.undoCount(), count + 1);
session.cancelTransform();
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(layerWith(session, image).transform.origin, QPointF(43, 43));
QCOMPARE(session.history.undoCount(), count + 1);
// A nudge past the valid origins moves nothing.
session.nudgeLayer(2'000'000, 0);
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(layerWith(session, image).transform.origin, QPointF(43, 43));
QCOMPARE(session.history.undoCount(), count + 1);
}
void TransformSessionTests::anotherLayerToolOrCanvasCommits()
{
EditorSession session;
session.createDocument(100, 100);
const QUuid first = insertImage(session, 20, 10, {50, 50});
const QUuid second = insertImage(session, 20, 10, {50, 50});
const auto nudged = [&](QUuid id) {
session.selectLayer(id);
session.selectTool(NavigationTool::move);
session.beginTransform();
session.nudgeLayer(1, 0);
return layerWith(session, id).transform.origin.x();
};
// The same layer and tool leave the edit open.
double x = nudged(second);
session.selectLayer(second);
session.selectTool(NavigationTool::move);
QVERIFY(session.transformEdit().has_value());
QCOMPARE(layerWith(session, second).transform.origin.x(), x);
session.selectLayer(first);
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(layerWith(session, second).transform.origin.x(), x + 1);
x = nudged(first);
session.selectLayers({first, second}, first);
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(layerWith(session, first).transform.origin.x(), x + 1);
// The same selection again commits nothing.
session.beginTransform();
session.nudgeLayer(5, 0);
session.selectLayers({first, second}, first);
QVERIFY(session.transformEdit().has_value());
session.cancelTransform();
x = nudged(second);
const int count = session.history.undoCount();
session.createDocument(50, 50);
QVERIFY(!session.transformEdit().has_value());
// The move, then the new canvas: two steps.
QCOMPARE(session.history.undoCount(), count + 2);
session.undo();
QCOMPARE(layerWith(session, second).transform.origin.x(), x + 1);
}
void TransformSessionTests::anOpenTransformClosesTheGates()
{
EditorSession session;
session.createDocument(100, 100);
const QUuid first = insertImage(session, 20, 10, {50, 50});
const QUuid second = insertImage(session, 20, 10, {50, 50});
QVERIFY(session.canEditLayers() && session.canUndo());
session.beginTransform();
QVERIFY(!session.canEditLayers() && !session.canUndo() && !session.canTransform());
session.addBlankLayer();
session.undo();
QCOMPARE(int(session.document().value().layers.size()), 2);
// A second begin keeps the draft of the first.
session.nudgeLayer(7, 0);
session.beginTransform();
QCOMPARE(session.transformEdit().value().draft.origin.x(), 47.0);
// Cmd-Shift-click still extends: the edit commits first.
session.extendSelection(first);
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(layerWith(session, second).transform.origin.x(), 47.0);
QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second}));
QVERIFY(session.canEditLayers() && session.canUndo());
}
void TransformSessionTests::handlesFollowTheDraftOrTheBox()
{
EditorSession session;
QCOMPARE(session.transformPixelSize(), std::nullopt);
session.createDocument(100, 100);
session.addBlankLayer();
QCOMPARE(session.transformPixelSize(), std::nullopt);
const QUuid image = insertImage(session, 20, 10, {50, 50});
const QUuid other = insertImage(session, 8, 6, {20, 20});
session.selectLayer(image);
QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(20, 10)));
QCOMPARE(session.editedTransform(layerWith(session, image)), layerWith(session, image).transform);
QCOMPARE(session.pendingTransform(layerWith(session, image)), std::nullopt);
session.beginTransform();
LayerTransform draft = session.transformEdit().value().draft;
draft.size = {60, 30};
draft.rotation = 45;
session.previewTransform(draft);
QCOMPARE(session.editedTransform(layerWith(session, image)), draft);
QCOMPARE(session.pendingTransform(layerWith(session, image)), std::optional(draft));
QCOMPARE(session.displayedTransform(layerWith(session, image)), draft);
QCOMPARE(session.editedTransform(layerWith(session, other)), layerWith(session, other).transform);
QCOMPARE(session.pendingTransform(layerWith(session, other)), std::nullopt);
// The pixels stay the image's, whatever the draft or placement.
QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(20, 10)));
session.commitTransform();
QCOMPARE(layerWith(session, image).transform.size, QSizeF(60, 30));
QCOMPARE(session.transformPixelSize(), std::optional(QSizeF(20, 10)));
// Made active behind an open edit, a folder shows none.
session.selectLayer(std::nullopt);
session.addGroup();
const QUuid folder = session.activeLayerID().value();
insertImage(session, 4, 4, {70, 70});
session.selectLayer(image);
session.beginTransform();
session.setActiveLayerID(folder);
QVERIFY(session.transformsAsGroup() && session.transformEdit().has_value());
QCOMPARE(session.editedTransform(layerWith(session, folder)), layerWith(session, folder).transform);
QCOMPARE(session.transformPixelSize(), std::nullopt);
session.cancelTransform();
QCOMPARE(session.editedTransform(layerWith(session, folder)), session.groupTransformBox().value());
}
QTEST_GUILESS_MAIN(TransformSessionTests)
#include "TransformSessionTests.moc"