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

265 lines
10 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "DialogDesk.h"
#include "IO/ProjectController.h"
#include "IO/ProjectStore.h"
#include "SessionRecord.h"
#include <QSemaphore>
#include <QTemporaryDir>
#include <QThreadPool>
#include <QtTest>
// Files dropped on a project: a queue of requests.
namespace {
const QString question = QStringLiteral("alert|2|Save changes to %1?|Your changes will be lost if you don’t save them.|Save,Cancel,Don’t Save");
// A four by two picture file of that name.
QUrl picture(const QTemporaryDir &folder, const QString &name)
{
QImage image(4, 2, QImage::Format_RGBA8888);
image.fill(Qt::green);
const QString path = folder.filePath(name + ".png");
if (!image.save(path))
throw std::runtime_error("the fixture could not be written");
return QUrl::fromLocalFile(path);
}
// A project of one blank layer on sixteen by twelve.
QUrl project(const QTemporaryDir &folder, const QString &name)
{
EditorSession session;
session.createDocument(16, 12);
session.addBlankLayer();
ProjectStore::save(session.projectSnapshot().value(), folder.filePath(name));
return QUrl::fromLocalFile(folder.filePath(name));
}
QStringList names(const EditorSession &session)
{
QStringList result;
for (const ImageLayer &layer : session.document().value().layers)
result << layer.name;
return result;
}
QPointF centre(const EditorSession &session, const QString &name)
{
for (const ImageLayer &layer : session.document().value().layers) {
if (layer.name == name)
return layer.transform.center();
}
throw std::runtime_error("no such layer");
}
}
class ProjectReceiveTests : public QObject {
Q_OBJECT
private slots:
void picturesGoInAtThePoint();
void aProjectOpensFirstAndItsPicturesGoToTheCentre();
void aProjectIsKnownByItsSuffixInAnyCase();
void twoProjectsAtOnceAreRefused();
void aProjectThatDoesNotOpenKeepsItsPicturesOut();
void requestsWaitForTheGateAndKeepTheirOrder();
void anEmptyDropAnswersFromTheEventLoop();
void aControllerThatEndsLeavesTheSessionSafe();
};
void ProjectReceiveTests::picturesGoInAtThePoint()
{
QTemporaryDir folder;
EditorSession session;
session.createDocument(16, 12);
ProjectController controller(session);
DialogDesk desk;
bool done = false;
controller.receive({picture(folder, "One"), picture(folder, "Two")}, QPointF(10, 5), [&] { done = true; });
// Nothing runs inside the call, not even the import's start.
QVERIFY(!done && !session.isImporting() && session.document().value().layers.empty());
QTRY_VERIFY(done);
QCOMPARE(names(session), (QStringList{"One", "Two"}));
QCOMPARE(centre(session, "One"), QPointF(10, 5));
QCOMPARE(centre(session, "Two"), QPointF(10, 5));
QVERIFY(desk.seen.isEmpty() && !session.isImporting());
// No callback is fine too.
controller.receive({picture(folder, "Three")});
QTRY_COMPARE(names(session).size(), 3);
QCOMPARE(centre(session, "Three"), QPointF(8, 6));
}
void ProjectReceiveTests::aProjectOpensFirstAndItsPicturesGoToTheCentre()
{
QTemporaryDir folder;
EditorSession session;
ProjectController controller(session);
DialogDesk desk;
bool done = false;
controller.receive({picture(folder, "Before"), project(folder, "Base.comp"), picture(folder, "After")}, QPointF(1, 1), [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(session.projectPath(), std::optional(folder.filePath("Base.comp")));
QCOMPARE(names(session), (QStringList{"Layer 1", "Before", "After"}));
// The point belonged to another canvas: the centre it is.
QCOMPARE(centre(session, "Before"), QPointF(8, 6));
QCOMPARE(centre(session, "After"), QPointF(8, 6));
QVERIFY(desk.seen.isEmpty() && !session.isProjectBusy());
// A project alone, over the unsaved pictures: asked, then opened.
done = false;
desk.replies = {"Don’t Save"};
controller.receive({project(folder, "Alone.comp")}, std::nullopt, [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(desk.seen, (QStringList{question.arg("Base.comp")}));
QCOMPARE(session.projectPath(), std::optional(folder.filePath("Alone.comp")));
QCOMPARE(names(session), (QStringList{"Layer 1"}));
}
void ProjectReceiveTests::aProjectIsKnownByItsSuffixInAnyCase()
{
QTemporaryDir folder;
EditorSession session;
ProjectController controller(session);
DialogDesk desk;
bool done = false;
// Upper case, and a folder's address ending in a slash.
const QUrl shouted = project(folder, "Shouted.COMP");
controller.receive({QUrl(shouted.toString() + "/")}, std::nullopt, [&] { done = true; });
QTRY_VERIFY(done);
QVERIFY(session.document().has_value());
QCOMPARE(session.projectPath(), std::optional(folder.filePath("Shouted.COMP")));
QCOMPARE(names(session), (QStringList{"Layer 1"}));
QVERIFY(desk.seen.isEmpty());
}
void ProjectReceiveTests::twoProjectsAtOnceAreRefused()
{
QTemporaryDir folder;
EditorSession session;
session.createDocument(16, 12);
ProjectController controller(session);
const QStringList before = described(session);
DialogDesk desk;
desk.replies = {"OK"};
bool done = false;
QTest::ignoreMessage(QtWarningMsg, "Open one project at a time: This is not a valid OmaPhoto project, or its metadata is damaged.");
controller.receive({project(folder, "A.comp"), picture(folder, "Picture"), project(folder, "B.comp")}, std::nullopt, [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(desk.seen, (QStringList{"alert|2|Open one project at a time|This is not a valid OmaPhoto project, or its metadata is damaged.|OK"}));
QCOMPARE(described(session), before);
}
void ProjectReceiveTests::aProjectThatDoesNotOpenKeepsItsPicturesOut()
{
QTemporaryDir folder;
EditorSession session;
session.createDocument(16, 12);
session.addBlankLayer();
ProjectController controller(session);
const QStringList before = described(session);
DialogDesk desk;
desk.replies = {"Cancel", "OK"};
bool done = false;
controller.receive({project(folder, "Refused.comp"), picture(folder, "Picture")}, std::nullopt, [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(desk.seen, (QStringList{question.arg("Untitled")}));
QCOMPARE(described(session), before);
// An address that is no local file fails to open.
done = false;
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("^Couldn’t open the project: .+"));
controller.receive({QUrl("https://example.com/Far.comp"), picture(folder, "Other")}, std::nullopt, [&] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(desk.seen.size(), 2);
QCOMPARE(described(session), before);
}
void ProjectReceiveTests::requestsWaitForTheGateAndKeepTheirOrder()
{
QTemporaryDir folder;
EditorSession session;
session.createDocument(16, 12);
ProjectController controller(session);
// A sheet is up: file requests wait.
session.setShowsNewDocument(true);
QStringList order;
// A caller resumes once the queue has moved on.
controller.receive({picture(folder, "First")}, std::nullopt, [&] { order << (session.isImporting() ? "first" : "first, queue idle"); });
controller.receive({picture(folder, "Second")}, std::nullopt, [&] { order << (session.isImporting() ? "second, queue busy" : "second"); });
QTest::qWait(100);
QVERIFY(order.isEmpty() && session.document().value().layers.empty());
session.setShowsNewDocument(false);
QTRY_COMPARE(order, (QStringList{"first", "second"}));
QCOMPARE(names(session), (QStringList{"First", "Second"}));
// The queue starts again for a later request.
controller.receive({picture(folder, "Third")}, std::nullopt, [&] { order << "third"; });
QTRY_COMPARE(order.size(), 3);
QCOMPARE(names(session), (QStringList{"First", "Second", "Third"}));
// A project's own pictures go in before the next request's.
DialogDesk desk;
desk.replies = {"Don’t Save"};
controller.receive({project(folder, "Base.comp"), picture(folder, "Own")}, std::nullopt, [&] { order << "fourth"; });
controller.receive({picture(folder, "Next")}, std::nullopt, [&] { order << "fifth"; });
QTRY_COMPARE(order.size(), 5);
QCOMPARE(order.mid(3), (QStringList{"fourth", "fifth"}));
QCOMPARE(names(session), (QStringList{"Layer 1", "Own", "Next"}));
}
void ProjectReceiveTests::anEmptyDropAnswersFromTheEventLoop()
{
EditorSession session;
ProjectController controller(session);
QSignalSpy changes(&session, &EditorSession::changed);
bool done = false;
controller.receive({}, std::nullopt, [&] { done = true; });
QVERIFY(!done);
QTRY_VERIFY(done);
controller.receive({});
QTest::qWait(20);
QCOMPARE(changes.count(), 0);
// It never queues: a closed gate does not hold it.
session.setShowsNewDocument(true);
done = false;
controller.receive({}, std::nullopt, [&] { done = true; });
QTRY_VERIFY(done);
}
void ProjectReceiveTests::aControllerThatEndsLeavesTheSessionSafe()
{
QTemporaryDir folder;
EditorSession session;
session.createDocument(16, 12);
auto controller = std::make_unique<ProjectController>(session);
// Behind a sheet the session itself holds the waiting request.
session.setShowsNewDocument(true);
bool done = false;
controller->receive({picture(folder, "Waiting")}, std::nullopt, [&] { done = true; });
QTest::qWait(50);
controller.reset();
session.setShowsNewDocument(false);
QTest::qWait(200);
QVERIFY(!done && !session.isImporting() && session.document().value().layers.empty());
// Mid-import: every worker is held, so the decode waits.
QThreadPool *pool = QThreadPool::globalInstance();
const int workers = pool->maxThreadCount();
QSemaphore held, gate;
const auto letGo = qScopeGuard([&] {
gate.release(workers);
pool->waitForDone();
});
for (int index = 0; index < workers; ++index) {
pool->start([&] {
held.release();
gate.acquire();
});
}
QVERIFY(held.tryAcquire(workers, 10'000));
controller = std::make_unique<ProjectController>(session);
controller->receive({picture(folder, "Landing")}, std::nullopt, [&] { done = true; });
QTRY_VERIFY(session.isImporting());
controller.reset();
gate.release(workers);
// The picture still lands; its request has nobody to tell.
QTRY_COMPARE(names(session), (QStringList{"Landing"}));
QTRY_VERIFY(!session.isImporting());
QTest::qWait(50);
QVERIFY(!done);
}
QTEST_MAIN(ProjectReceiveTests)
#include "ProjectReceiveTests.moc"