Files
OmaPhoto/tests/ProjectSessionTests.cpp
T

305 lines
15 KiB
C++

#include "Document/EditorSession.h"
#include <QSignalSpy>
#include <QtTest>
// `EditorSession+Projects`: snapshots out, projects in.
namespace {
ImportedImage pixels(int width, int height, const QString &name)
{
QImage image(width, height, QImage::Format_RGBA8888_Premultiplied);
image.fill(Qt::red);
return ImportedImage(image, image, name);
}
ImportedImage gray(int width, int height, int level)
{
QImage image(width, height, QImage::Format_Grayscale8);
image.fill(level);
return LayerMask::assetFrom(image);
}
// Two layers in a folder, no field at its default.
ProjectSnapshot fixture()
{
const QUuid folder = QUuid::createUuid(), lower = QUuid::createUuid(), upper = QUuid::createUuid();
const QString lowerFile = uuidString(lower), upperFile = uuidString(upper);
ProjectSnapshot snapshot{.manifest = {.resolution = 300, .documentID = QUuid::createUuid(), .width = 320, .height = 200,
.activeLayerID = upper, .layers = {}},
.images = {}};
snapshot.manifest.layers = {
{.id = folder, .name = "Artwork", .isVisible = false, .transform = {.origin = {0, 0}, .size = {320, 200}}, .imageFile = std::nullopt,
.isGroup = true, .opacity = 1, .blendMode = LayerBlendMode::normal},
{.id = lower, .name = "Lower", .isVisible = true, .transform = {.origin = {10, 20}, .size = {40, 30}, .rotation = 15, .flipX = true,
.sampling = LayerSampling::nearest},
.imageFile = lowerFile + ".png", .parentID = folder, .isGroup = false, .opacity = 0.25, .blendMode = LayerBlendMode::multiply,
.maskFile = lowerFile + ".mask.png", .maskEnabled = false,
.maskPlacement = LayerTransform{.origin = {1, 2}, .size = {8, 6}}, .maskLinked = false},
{.id = upper, .name = "Upper", .isVisible = false, .transform = {.origin = {-5, 7}, .size = {16, 8}, .flipY = true,
.sampling = LayerSampling::smooth},
.imageFile = upperFile + ".png", .parentID = folder, .isGroup = false, .opacity = 0.75, .blendMode = LayerBlendMode::screen,
.maskFile = upperFile + ".mask.png", .maskEnabled = true, .maskSourceID = lower, .maskLinked = true},
};
// Files keep the names they came with; layers get renamed.
snapshot.images.insert({lower, pixels(40, 30, "IMG_0001")});
snapshot.images.insert({upper, pixels(16, 8, "IMG_0002")});
snapshot.masks.insert({lower, gray(8, 6, 64)});
snapshot.masks.insert({upper, gray(16, 8, 200)});
return snapshot;
}
}
class ProjectSessionTests : public QObject {
Q_OBJECT
private slots:
void installingRebuildsEveryField();
void aSnapshotGivesBackWhatWasInstalled();
void installingStartsAFreshSession();
void clearingLeavesNothingBehind();
void aNewProjectIsOneEmptyLayerAndNoHistory();
void applyingASizeIsOneNamedStepOnTheSameCanvas();
void installingAndClearingFreeFileRequests();
};
void ProjectSessionTests::installingRebuildsEveryField()
{
const ProjectSnapshot snapshot = fixture();
EditorSession session;
QTest::ignoreMessage(QtInfoMsg, QRegularExpression("^installed a project of 3 layers from \\S*art\\.comp$"));
session.installProject(snapshot, "/projects/art.comp");
const CanvasDocument document = session.document().value();
QCOMPARE(document.id, snapshot.manifest.documentID);
QCOMPARE(document.size(), QSizeF(320, 200));
QCOMPARE(document.resolution, 300.0);
QCOMPARE(session.projectPath(), std::optional(QString("/projects/art.comp")));
QCOMPARE(session.activeLayerID(), snapshot.manifest.activeLayerID);
QCOMPARE(int(document.layers.size()), 3);
for (size_t index = 0; index < 3; ++index) {
const ProjectLayerRecord &record = snapshot.manifest.layers[index];
const ImageLayer &layer = document.layers[index];
QCOMPARE(layer.id, record.id);
QCOMPARE(layer.name, record.name);
QCOMPARE(layer.isVisible, record.isVisible);
QCOMPARE(layer.transform, record.transform);
QCOMPARE(layer.parentID, record.parentID);
QCOMPARE(layer.isGroup, record.isGroup.value());
QCOMPARE(layer.opacity, record.opacity.value());
QCOMPARE(layer.blendMode, record.blendMode.value());
QCOMPARE(layer.maskSourceID, record.maskSourceID);
QCOMPARE(layer.asset.has_value(), record.imageFile.has_value());
QCOMPARE(layer.mask.has_value(), record.maskFile.has_value());
}
// Pixels and masks are shared, not copied.
const ImageLayer &lower = document.layers[1], &upper = document.layers[2];
QVERIFY(lower.asset.value().identity() == snapshot.images.at(lower.id).identity());
QVERIFY(upper.asset.value().identity() == snapshot.images.at(upper.id).identity());
QCOMPARE(lower.mask.value(), LayerMask(snapshot.masks.at(lower.id), false, LayerTransform{.origin = {1, 2}, .size = {8, 6}}, false));
QCOMPARE(upper.mask.value(), LayerMask(snapshot.masks.at(upper.id), true, std::nullopt, true));
// What a record leaves out takes Swift's defaults.
ProjectSnapshot bare = snapshot;
bare.manifest.resolution = std::nullopt;
for (ProjectLayerRecord &record : bare.manifest.layers) {
record.isGroup = std::nullopt;
record.opacity = std::nullopt;
record.blendMode = std::nullopt;
}
QTest::ignoreMessage(QtInfoMsg, QRegularExpression("^installed a project of 3 layers .*"));
session.installProject(bare, "bare.comp");
QCOMPARE(session.projectPath(), std::optional(QString("bare.comp")));
QCOMPARE(session.document().value().resolution, 72.0);
for (const ImageLayer &layer : session.document().value().layers) {
QVERIFY(!layer.isGroup);
QCOMPARE(layer.opacity, 1.0);
QCOMPARE(layer.blendMode, LayerBlendMode::normal);
}
}
void ProjectSessionTests::aSnapshotGivesBackWhatWasInstalled()
{
QVERIFY(!EditorSession().projectSnapshot().has_value());
const ProjectSnapshot snapshot = fixture();
EditorSession session;
session.installProject(snapshot, "art.comp");
const ProjectSnapshot again = session.projectSnapshot().value();
// The same manifest to the byte; pixels by identity.
QCOMPARE(again.manifest.encoded(), snapshot.manifest.encoded());
QCOMPARE(again.manifest.version, qint64(11));
QCOMPARE(int(again.images.size()), 2);
QCOMPARE(int(again.masks.size()), 2);
for (const auto &[id, image] : snapshot.images)
QVERIFY(again.images.at(id).identity() == image.identity());
for (const auto &[id, mask] : snapshot.masks)
QVERIFY(again.masks.at(id).identity() == mask.identity());
// The active layer of the moment goes into the manifest.
session.selectLayer(snapshot.manifest.layers[1].id);
QCOMPARE(session.projectSnapshot().value().manifest.activeLayerID, std::optional(snapshot.manifest.layers[1].id));
session.selectLayer(std::nullopt);
QCOMPARE(session.projectSnapshot().value().manifest.activeLayerID, std::nullopt);
// A canvas without layers still has a snapshot.
EditorSession bare;
bare.createDocument(7, 5);
const ProjectSnapshot blank = bare.projectSnapshot().value();
QCOMPARE(blank.manifest.documentID, bare.document().value().id);
QCOMPARE(blank.manifest.width, qint64(7));
QCOMPARE(blank.manifest.height, qint64(5));
QCOMPARE(blank.manifest.resolution, std::optional(72.0));
QVERIFY(blank.manifest.layers.empty() && blank.images.empty() && blank.masks.empty());
QCOMPARE(blank.manifest.activeLayerID, std::nullopt);
}
void ProjectSessionTests::installingStartsAFreshSession()
{
EditorSession session;
session.viewport.resize(QSizeF(400, 300), 1, std::nullopt);
session.createDocument(64, 64);
session.addGroup();
const QUuid old = session.activeLayerID().value();
session.insert(pixels(8, 8, "Old"));
session.selectLayer(old);
session.toggleGroupExpansion(old);
session.selectLayer(session.document().value().layers.back().id);
session.beginTransform();
session.setRenamingLayerID(old);
session.zoom(3);
QVERIFY(session.transformEdit().has_value() && !session.collapsedGroupIDs().isEmpty() && session.history.undoCount() > 0);
const ProjectSnapshot snapshot = fixture();
session.installProject(snapshot, "art.comp");
QVERIFY(!session.transformEdit().has_value());
QVERIFY(session.collapsedGroupIDs().isEmpty());
QVERIFY(!session.renamingLayerID().has_value());
QCOMPARE(session.history.undoCount(), 0);
QVERIFY(!session.canUndo() && !session.canRedo() && !session.isModified());
QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{snapshot.manifest.activeLayerID.value()});
// The view fits the canvas that came in.
QVERIFY(session.viewport.followsFit());
EditorSession fitted;
fitted.viewport.resize(QSizeF(400, 300), 1, std::nullopt);
fitted.createDocument(320, 200);
QCOMPARE(session.viewport.zoom(), fitted.viewport.zoom());
// The first edit after installing is the first step.
session.addBlankLayer();
QCOMPARE(session.history.undoCount(), 1);
QVERIFY(session.isModified());
// A project saved with nothing selected opens that way.
ProjectSnapshot unselected = fixture();
unselected.manifest.activeLayerID = std::nullopt;
QVERIFY(session.activeLayerID().has_value());
session.installProject(unselected, "unselected.comp");
QVERIFY(!session.activeLayerID().has_value() && session.selectedLayerIDs().isEmpty());
QCOMPARE(int(session.document().value().layers.size()), 3);
// An empty canvas is a project too.
const ProjectSnapshot empty{.manifest = {.documentID = QUuid::createUuid(), .width = 7, .height = 5, .activeLayerID = std::nullopt, .layers = {}},
.images = {}};
session.installProject(empty, "empty.comp");
QCOMPARE(session.document().value().id, empty.manifest.documentID);
QCOMPARE(session.document().value().size(), QSizeF(7, 5));
QVERIFY(session.document().value().layers.empty());
}
void ProjectSessionTests::clearingLeavesNothingBehind()
{
EditorSession session;
session.installProject(fixture(), "art.comp");
session.toggleGroupExpansion(session.document().value().layers.front().id);
session.selectLayer(session.document().value().layers[1].id);
session.toggleLayerVisibility(session.document().value().layers.front().id);
session.beginTransform();
session.setRenamingLayerID(QUuid::createUuid());
session.clearProject();
QVERIFY(!session.document().has_value() && !session.activeLayerID().has_value() && session.selectedLayerIDs().isEmpty());
QVERIFY(!session.projectPath().has_value() && !session.renamingLayerID().has_value());
QVERIFY(!session.transformEdit().has_value() && session.collapsedGroupIDs().isEmpty());
QCOMPARE(session.history.undoCount(), 0);
QVERIFY(!session.isModified() && !session.projectSnapshot().has_value());
}
void ProjectSessionTests::aNewProjectIsOneEmptyLayerAndNoHistory()
{
EditorSession session;
session.installProject(fixture(), "art.comp");
session.createNewProject(640, 480);
QCOMPARE(session.document().value().size(), QSizeF(640, 480));
QCOMPARE(int(session.document().value().layers.size()), 1);
QVERIFY(!session.projectPath().has_value());
// The canvas itself is the one step, as in Swift.
QCOMPARE(session.history.undoCount(), 1);
const std::optional<CanvasDocument> kept = session.document();
// Refused sizes and busy sessions keep what they have.
for (const auto &[width, height] : {std::pair(0, 10), std::pair(10, 0), std::pair(30'001, 10), std::pair(10, 30'001)}) {
session.createNewProject(width, height);
QCOMPARE(session.document(), kept);
}
session.setIsImporting(true);
session.createNewProject(10, 10);
session.setIsImporting(false);
session.setIsProjectBusy(true);
session.createNewProject(10, 10);
QCOMPARE(session.document(), kept);
session.setIsProjectBusy(false);
// Both ends of both sides are taken.
session.createNewProject(30'000, 1);
QCOMPARE(session.document().value().size(), QSizeF(30'000, 1));
session.createNewProject(1, 30'000);
QCOMPARE(session.document().value().size(), QSizeF(1, 30'000));
}
void ProjectSessionTests::applyingASizeIsOneNamedStepOnTheSameCanvas()
{
EditorSession session;
session.viewport.resize(QSizeF(400, 300), 1, std::nullopt);
const ProjectSnapshot snapshot = fixture();
session.applyImageSize(snapshot);
QVERIFY(!session.document().has_value());
session.installProject(snapshot, "art.comp");
const std::optional<CanvasDocument> before = session.document();
// Another canvas's pixels are refused in silence.
ProjectSnapshot other = fixture();
QSignalSpy changes(&session, &EditorSession::changed);
session.applyDocumentSize(other, "Canvas Size");
QCOMPARE(session.document(), before);
QCOMPARE(int(changes.count()), 0);
QCOMPARE(session.history.undoCount(), 0);
// The same canvas, half as large, with one layer gone.
ProjectSnapshot smaller = snapshot;
smaller.manifest.width = 160;
smaller.manifest.height = 100;
smaller.manifest.resolution = 150;
smaller.manifest.layers.pop_back();
// What the snapshot calls active is not the session's affair.
smaller.manifest.activeLayerID = smaller.manifest.layers.front().id;
session.zoom(3);
session.applyDocumentSize(smaller, "Canvas Size");
QCOMPARE(session.activeLayerID(), snapshot.manifest.activeLayerID);
QCOMPARE(session.projectPath(), std::optional(QString("art.comp")));
QCOMPARE(session.document().value().size(), QSizeF(160, 100));
QCOMPARE(session.document().value().resolution, 150.0);
QCOMPARE(int(session.document().value().layers.size()), 2);
QCOMPARE(session.history.undoCount(), 1);
QCOMPARE(session.history.undoName(), QString("Canvas Size"));
QVERIFY(session.viewport.followsFit());
session.undo();
QCOMPARE(session.document(), before);
session.redo();
QCOMPARE(session.document().value().size(), QSizeF(160, 100));
session.undo();
session.applyImageSize(smaller);
QCOMPARE(session.history.undoName(), QString("Image Size"));
}
void ProjectSessionTests::installingAndClearingFreeFileRequests()
{
for (const bool clears : {false, true}) {
EditorSession session;
session.setRenamingLayerID(QUuid::createUuid());
bool ran = false;
session.waitForFileRequest([&] { ran = true; });
if (clears)
session.clearProject();
else
session.installProject(fixture(), "art.comp");
QVERIFY(QTest::qWaitFor([&] { return ran; }, 5'000));
}
}
QTEST_GUILESS_MAIN(ProjectSessionTests)
#include "ProjectSessionTests.moc"