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

495 lines
20 KiB
C++

#include "Document/EditorSession.h"
#include "SessionFixtures.h"
#include <QtTest>
namespace {
ImportedImage asset(int width, int height, const QString &name)
{
return ImportedImage(QImage(width, height, QImage::Format_RGBA8888_Premultiplied), QImage(), name);
}
QStringList names(const EditorSession &session)
{
QStringList result;
for (const ImageLayer &layer : session.document().value().layers)
result << layer.name;
return result;
}
ImageLayer layerNamed(const EditorSession &session, const QString &name)
{
for (const ImageLayer &layer : session.document().value().layers) {
if (layer.name == name)
return layer;
}
throw std::runtime_error("no layer named " + name.toStdString());
}
}
class EditorSessionTests : public QObject {
Q_OBJECT
private slots:
void settersKeepExactlyWhatTheyAreGiven();
void theSelectionFollowsTheActiveLayer();
void aBusyProjectKeepsItsTool();
void tabSwitchesEachToolsShapeOrMode();
void everyGateClosesLayerEdits_data();
void everyGateClosesLayerEdits();
void theFirstImageMakesTheCanvas();
void imagesLandCentredOnWholePixels();
void newLayersJoinTheActiveFolder();
void foldersAreNamedPlacedAndLimited();
void aWideFolderIsWalkedOnce();
void foldingAFolderTakesTheSelectionFromItsContents();
void deletingAFolderTakesItsContents();
void movingStaysAmongSiblings();
void duplicatingALayerByDraggingPlacesTheCopyAsOneStep();
void aNewCanvasCanStartWithALayer();
void sessionChangesAreLogged();
};
void EditorSessionTests::settersKeepExactlyWhatTheyAreGiven()
{
EditorSession session;
const auto flags = [&] {
return QList<bool>{session.isProjectBusy(), session.showsNewDocument(), session.showsImporter(), session.isImporting()};
};
QCOMPARE(flags(), (QList<bool>{false, false, false, false}));
QVERIFY(!session.importError().has_value() && !session.renamingLayerID().has_value());
// Each flag is its own.
session.setIsProjectBusy(true);
QCOMPARE(flags(), (QList<bool>{true, false, false, false}));
session.setIsProjectBusy(false);
session.setShowsNewDocument(true);
QCOMPARE(flags(), (QList<bool>{false, true, false, false}));
session.setShowsNewDocument(false);
session.setShowsImporter(true);
QCOMPARE(flags(), (QList<bool>{false, false, true, false}));
session.setShowsImporter(false);
session.setIsImporting(true);
QCOMPARE(flags(), (QList<bool>{false, false, false, true}));
session.setIsImporting(false);
QCOMPARE(flags(), (QList<bool>{false, false, false, false}));
const QUuid first = QUuid::createUuid(), second = QUuid::createUuid();
session.setRenamingLayerID(first);
session.setImportError(QString("first.png: unreadable"));
QCOMPARE(session.renamingLayerID(), std::optional(first));
QCOMPARE(session.importError(), std::optional(QString("first.png: unreadable")));
session.setRenamingLayerID(second);
session.setImportError(QString("second.png: too large"));
QCOMPARE(session.renamingLayerID(), std::optional(second));
QCOMPARE(session.importError(), std::optional(QString("second.png: too large")));
session.setRenamingLayerID(std::nullopt);
session.setImportError(std::nullopt);
QVERIFY(!session.importError().has_value() && !session.renamingLayerID().has_value());
QCOMPARE(flags(), (QList<bool>{false, false, false, false}));
}
void EditorSessionTests::theSelectionFollowsTheActiveLayer()
{
EditorSession session;
session.createDocument(64, 64);
QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>());
QVERIFY(!session.activeLayer().has_value());
session.addBlankLayer();
const QUuid first = session.activeLayerID().value();
session.addBlankLayer();
QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{session.activeLayerID().value()});
session.selectLayer(first);
QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{first});
QCOMPARE(session.activeLayer().value().name, QString("Layer 1"));
session.selectLayer(std::nullopt);
QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>());
// An id the document does not hold selects no layer.
session.setActiveLayerID(QUuid::createUuid());
QVERIFY(!session.activeLayer().has_value());
}
void EditorSessionTests::aBusyProjectKeepsItsTool()
{
EditorSession session;
QCOMPARE(session.tool(), NavigationTool::move);
session.selectTool(NavigationTool::hand);
QCOMPARE(session.tool(), NavigationTool::hand);
session.setIsProjectBusy(true);
session.selectTool(NavigationTool::zoom);
QCOMPARE(session.tool(), NavigationTool::hand);
}
void EditorSessionTests::everyGateClosesLayerEdits_data()
{
QTest::addColumn<int>("gate");
QTest::newRow("a busy project") << 0;
QTest::newRow("an import") << 1;
QTest::newRow("the new canvas sheet") << 2;
QTest::newRow("the importer") << 3;
QTest::newRow("a rename") << 4;
}
void EditorSessionTests::everyGateClosesLayerEdits()
{
QFETCH(int, gate);
EditorSession session;
QVERIFY(!session.canEditLayers());
session.createDocument(64, 64);
session.addBlankLayer();
QVERIFY(session.canEditLayers());
const auto set = [&](bool on) {
if (gate == 0)
session.setIsProjectBusy(on);
else if (gate == 1)
session.setIsImporting(on);
else if (gate == 2)
session.setShowsNewDocument(on);
else if (gate == 3)
session.setShowsImporter(on);
else
session.setRenamingLayerID(on ? std::optional(QUuid::createUuid()) : std::nullopt);
};
set(true);
QVERIFY(!session.canEditLayers());
session.addBlankLayer();
QCOMPARE(int(session.document().value().layers.size()), 1);
set(false);
QVERIFY(session.canEditLayers());
// An import error stops history, not layer edits.
session.setImportError(QString("failed"));
QVERIFY(session.canEditLayers() && !session.canUseHistory());
}
void EditorSessionTests::tabSwitchesEachToolsShapeOrMode()
{
EditorSession session;
session.createDocument(20, 20);
session.addBlankLayer();
session.selectTool(NavigationTool::marquee);
session.cycleToolMode();
QCOMPARE(session.marqueeKind(), LassoKind::ellipse);
session.selectTool(NavigationTool::lasso);
session.cycleToolMode();
QCOMPARE(session.lassoKind(), LassoKind::polygonal);
session.cycleToolMode();
QCOMPARE(session.lassoKind(), LassoKind::freehand);
QCOMPARE(session.marqueeKind(), LassoKind::ellipse);
session.selectTool(NavigationTool::brush);
session.cycleToolMode();
QCOMPARE(session.brushMode(), BrushToolMode::erase);
// Busy or mid-stroke, nothing switches, and nothing is said.
QSignalSpy changes(&session, &EditorSession::changed);
session.setIsProjectBusy(true);
changes.clear();
session.cycleToolMode();
session.setIsProjectBusy(false);
session.beginBrush(QPointF(5, 5));
changes.clear();
session.cycleToolMode();
QCOMPARE(changes.count(), 0);
QCOMPARE(session.brushMode(), BrushToolMode::erase);
session.cancelBrush();
// A tool without modes stays silent.
session.selectTool(NavigationTool::hand);
changes.clear();
session.cycleToolMode();
QCOMPARE(changes.count(), 0);
}
void EditorSessionTests::theFirstImageMakesTheCanvas()
{
EditorSession session;
session.viewport.resize(QSizeF(400, 300), 1, std::nullopt);
session.viewport.setZoom(3, session.viewport.center(), QSizeF(64, 32));
session.insert(asset(64, 32, "First"), QPointF(999, 999));
const CanvasDocument document = session.document().value();
QCOMPARE(document.size(), QSizeF(64, 32));
QVERIFY(session.viewport.followsFit());
QCOMPARE(session.history.undoName(), QString("Import Image"));
// The drop point still places the layer.
QCOMPARE(document.layers[0].origin(), QPointF(967, 983));
QCOMPARE(session.activeLayerID(), std::optional(document.layers[0].id));
session.undo();
QVERIFY(!session.document().has_value());
}
void EditorSessionTests::imagesLandCentredOnWholePixels()
{
EditorSession session;
session.createDocument(101, 51);
session.insert(asset(64, 32, "Centred"));
// Half pixels fall to the left and up.
QCOMPARE(session.activeLayer().value().origin(), QPointF(18, 9));
QCOMPARE(session.activeLayer().value().size(), QSizeF(64, 32));
// Odd in odd centres exactly: both halves count.
session.insert(asset(65, 33, "Odd"));
QCOMPARE(session.activeLayer().value().origin(), QPointF(18, 9));
session.insert(asset(64, 33, "Dropped"), QPointF(10.25, -4.5));
QCOMPARE(session.activeLayer().value().origin(), QPointF(-22, -21));
QCOMPARE(names(session), (QStringList{"Centred", "Odd", "Dropped"}));
QCOMPARE(session.document().value().size(), QSizeF(101, 51));
}
void EditorSessionTests::newLayersJoinTheActiveFolder()
{
EditorSession session;
session.createDocument(64, 64);
session.addBlankLayer();
session.addGroup();
const QUuid folder = session.activeLayerID().value();
session.addBlankLayer();
QCOMPARE(session.activeLayer().value().parentID, std::optional(folder));
// Beside a layer in the folder, the next joins it.
session.addBlankLayer();
QCOMPARE(session.activeLayer().value().parentID, std::optional(folder));
session.insert(asset(4, 4, "Image"));
QCOMPARE(session.activeLayer().value().parentID, std::optional(folder));
session.selectLayer(folder);
session.toggleGroupExpansion(folder);
QVERIFY(session.collapsedGroupIDs().contains(folder));
// Filling a folded folder opens it, above all it holds.
session.addBlankLayer();
QVERIFY(!session.collapsedGroupIDs().contains(folder));
QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2", "Layer 3", "Image", "Layer 4"}));
session.selectLayer(folder);
session.toggleGroupExpansion(folder);
session.insert(asset(4, 4, "Inside"));
QVERIFY(!session.collapsedGroupIDs().contains(folder));
QCOMPARE(session.activeLayer().value().parentID, std::optional(folder));
// A nested folder's contents count as the outer folder's.
session.addGroup();
const QUuid inner = session.activeLayerID().value();
session.addBlankLayer();
QCOMPARE(session.activeLayer().value().parentID, std::optional(inner));
session.selectLayer(folder);
session.addBlankLayer();
QCOMPARE(names(session).last(), QString("Layer 6"));
QCOMPARE(session.activeLayer().value().parentID, std::optional(folder));
session.selectLayer(std::nullopt);
session.insert(asset(4, 4, "Outside"));
QCOMPARE(session.activeLayer().value().parentID, std::nullopt);
}
void EditorSessionTests::foldersAreNamedPlacedAndLimited()
{
EditorSession session;
session.createDocument(64, 64);
session.addBlankLayer();
session.addBlankLayer();
session.selectLayer(session.document().value().layers[0].id);
session.addGroup();
QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2"}));
QCOMPARE(session.history.undoName(), QString("New Folder"));
const ImageLayer folder = session.activeLayer().value();
QVERIFY(folder.isGroup && !folder.asset.has_value());
QCOMPARE(folder.size(), QSizeF(64, 64));
QCOMPARE(folder.parentID, std::nullopt);
// Each new folder nests in the active one, 64 deep.
for (int depth = 0; depth < 70; ++depth)
session.addGroup();
QCOMPARE(int(session.document().value().layers.size()), 2 + 64);
QCOMPARE(session.activeLayer().value().name, QString("Folder 64"));
session.toggleGroupExpansion(folder.id);
QCOMPARE(session.activeLayerID(), std::optional(folder.id));
session.addGroup();
QVERIFY(!session.collapsedGroupIDs().contains(folder.id));
// Ten thousand layers leave no room for a folder.
EditorSession crowded;
crowded.createDocument(8, 8);
crowded.beginEdit("Fill");
const ImportedImage dot = asset(1, 1, "Dot");
for (int index = 0; index < 9'999; ++index)
crowded.insert(dot);
crowded.endEdit();
crowded.addGroup();
QCOMPARE(int(crowded.document().value().layers.size()), 10'000);
crowded.addGroup();
QCOMPARE(int(crowded.document().value().layers.size()), 10'000);
}
void EditorSessionTests::aWideFolderIsWalkedOnce()
{
EditorSession session;
session.createDocument(8, 8);
session.addGroup();
const QUuid folder = session.activeLayerID().value();
session.beginEdit("Fill");
const ImportedImage dot = asset(1, 1, "Dot");
for (int index = 0; index < 9'999; ++index) {
session.selectLayer(folder);
session.insert(dot);
}
session.endEdit();
// Copying the layers for each child would take minutes.
QElapsedTimer timer;
timer.start();
QCOMPARE(int(session.descendantIDs(folder).size()), 9'999);
QVERIFY2(timer.elapsed() < 2'000, qPrintable(QString::number(timer.elapsed())));
}
void EditorSessionTests::foldingAFolderTakesTheSelectionFromItsContents()
{
EditorSession session;
session.createDocument(64, 64);
session.addBlankLayer();
const QUuid outside = session.activeLayerID().value();
session.addGroup();
const QUuid folder = session.activeLayerID().value();
session.addGroup();
const QUuid inner = session.activeLayerID().value();
session.addBlankLayer();
const QUuid deep = session.activeLayerID().value();
QCOMPARE(session.descendantIDs(folder), (QSet<QUuid>{inner, deep}));
QCOMPARE(session.descendantIDs(inner), QSet<QUuid>{deep});
QCOMPARE(session.descendantIDs(deep), QSet<QUuid>());
QCOMPARE(session.descendantIDs(QUuid::createUuid()), QSet<QUuid>());
QCOMPARE(EditorSession().descendantIDs(folder), QSet<QUuid>());
session.toggleGroupExpansion(folder);
QCOMPARE(session.activeLayerID(), std::optional(folder));
QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{folder});
session.toggleGroupExpansion(folder);
QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>());
// A selection outside stays; layers and strangers do not fold.
session.selectLayer(outside);
session.toggleGroupExpansion(inner);
QCOMPARE(session.activeLayerID(), std::optional(outside));
session.toggleGroupExpansion(outside);
session.toggleGroupExpansion(QUuid::createUuid());
QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{inner});
session.setIsProjectBusy(true);
session.toggleGroupExpansion(inner);
QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{inner});
}
void EditorSessionTests::deletingAFolderTakesItsContents()
{
EditorSession session;
session.createDocument(64, 64);
session.addBlankLayer();
session.addGroup();
const QUuid folder = session.activeLayerID().value();
session.addBlankLayer();
session.addGroup();
session.addBlankLayer();
session.selectLayer(std::nullopt);
session.addBlankLayer();
QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2", "Folder 2", "Layer 3", "Layer 4"}));
session.selectLayer(layerNamed(session, "Layer 3").id);
const int count = session.history.undoCount();
session.deleteLayer(folder);
QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 4"}));
// The folder sat at index 1; its successor is selected.
QCOMPARE(session.activeLayer().value().name, QString("Layer 4"));
QCOMPARE(session.history.undoCount(), count + 1);
session.undo();
QCOMPARE(int(session.document().value().layers.size()), 6);
QCOMPARE(session.activeLayer().value().name, QString("Layer 3"));
// Deleting something else leaves the selection alone.
session.deleteLayer(layerNamed(session, "Layer 4").id);
QCOMPARE(session.activeLayer().value().name, QString("Layer 3"));
}
void EditorSessionTests::movingStaysAmongSiblings()
{
EditorSession session;
session.createDocument(64, 64);
session.addBlankLayer();
session.addGroup();
session.addBlankLayer();
session.addBlankLayer();
session.selectLayer(std::nullopt);
session.addBlankLayer();
QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2", "Layer 3", "Layer 4"}));
// Layer 4's siblings: Layer 1 and the folder, both below.
QVERIFY(session.canMoveActiveLayer(-2) && !session.canMoveActiveLayer(-3) && !session.canMoveActiveLayer(1));
session.moveActiveLayer(-1);
QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 4", "Layer 2", "Layer 3", "Folder 1"}));
// Inside the folder two layers trade places and no more.
session.selectLayer(layerNamed(session, "Layer 2").id);
QVERIFY(session.canMoveActiveLayer(1) && !session.canMoveActiveLayer(-1) && !session.canMoveActiveLayer(2));
session.moveActiveLayer(1);
QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 4", "Layer 3", "Layer 2", "Folder 1"}));
session.selectLayer(std::nullopt);
QVERIFY(!session.canMoveActiveLayer(0));
}
void EditorSessionTests::aNewCanvasCanStartWithALayer()
{
EditorSession session;
session.setShowsNewDocument(true);
session.setRenamingLayerID(QUuid::createUuid());
session.createDocument(640, 480, true);
const CanvasDocument document = session.document().value();
QCOMPARE(int(document.layers.size()), 1);
QCOMPARE(session.activeLayer().value().name, QString("Layer 1"));
QVERIFY(!session.activeLayer().value().asset.has_value());
QCOMPARE(session.activeLayer().value().size(), QSizeF(640, 480));
QVERIFY(!session.showsNewDocument() && !session.renamingLayerID().has_value());
QCOMPARE(document.resolution, 72.0);
// A plain canvas selects nothing; busy or importing makes none.
session.createDocument(10, 10);
QVERIFY(!session.activeLayerID().has_value() && session.document().value().layers.empty());
session.setIsProjectBusy(true);
session.createDocument(20, 20);
session.setIsProjectBusy(false);
session.setIsImporting(true);
session.createDocument(30, 30);
QCOMPARE(session.document().value().size(), QSizeF(10, 10));
}
void EditorSessionTests::sessionChangesAreLogged()
{
EditorSession session;
QTest::ignoreMessage(QtInfoMsg, "new canvas 12 x 34");
session.createDocument(12, 34);
session.addBlankLayer();
QTest::ignoreMessage(QtInfoMsg, "restored a history snapshot of 0 layers");
session.undo();
}
// Alt-dragging a row drops a duplicate, one step.
void EditorSessionTests::duplicatingALayerByDraggingPlacesTheCopyAsOneStep()
{
EditorSession session;
session.createDocument(800, 600);
for (int each = 0; each < 3; ++each)
session.addBlankLayer();
const std::vector<LayerHierarchy::Entry> rows = session.layerRows();
const ProjectLayerRecord top = rows.front().layer, bottom = rows.back().layer;
const std::optional<CanvasDocument> before = session.document();
const int count = session.history.undoCount();
QVERIFY(session.duplicateLayer(bottom.id, std::nullopt, top.id));
const std::vector<LayerHierarchy::Entry> after = session.layerRows();
QCOMPARE(after.size(), size_t(4));
QCOMPARE(session.history.undoCount(), count + 1);
QCOMPARE(session.history.undoName(), QString("Duplicate Layer"));
QCOMPARE(after.front().layer.name, bottom.name + " copy");
QCOMPARE(after.front().layer.id, session.activeLayerID());
QVERIFY(indexOf(session.document().value().layers, bottom.id) >= 0);
session.undo();
QCOMPARE(session.document(), before);
// Folders are not duplicated this way; neither is a stranger.
session.addGroup();
const QUuid folder = session.activeLayerID().value();
QVERIFY(!session.duplicateLayer(folder, std::nullopt, std::nullopt, true));
QVERIFY(!session.duplicateLayer(QUuid::createUuid(), std::nullopt));
QVERIFY(!session.duplicateLayer(bottom.id, bottom.id));
QCOMPARE(session.layerRows().size(), size_t(4));
// Into a folder, at its top.
QVERIFY(session.duplicateLayer(bottom.id, folder));
QCOMPARE(layerWith(session, session.activeLayerID().value()).parentID, std::optional(folder));
session.setIsImporting(true);
QVERIFY(!session.duplicateLayer(bottom.id, std::nullopt));
session.setIsImporting(false);
// The copy sits right above; a folder makes none.
session.selectLayer(top.id);
session.duplicateActiveLayer();
const int original = indexOf(session.document().value().layers, top.id);
QCOMPARE(session.document().value().layers[original + 1].name, top.name + " copy");
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[original + 1].id));
const size_t kept = session.document().value().layers.size();
session.selectLayer(folder);
session.duplicateActiveLayer();
QCOMPARE(session.document().value().layers.size(), kept);
}
QTEST_GUILESS_MAIN(EditorSessionTests)
#include "EditorSessionTests.moc"