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>
306 lines
12 KiB
C++
306 lines
12 KiB
C++
#include "DialogDesk.h"
|
||
#include "IO/ProjectController.h"
|
||
#include "IO/ProjectStore.h"
|
||
#include "SessionRecord.h"
|
||
#include <QFutureWatcher>
|
||
#include <QTemporaryDir>
|
||
#include <QtTest>
|
||
#include <atomic>
|
||
|
||
// Opening a project: validated first, asked second, installed last.
|
||
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");
|
||
const QString damaged = QStringLiteral("alert|2|Couldn’t open the project|This is not a valid OmaPhoto project, or its metadata is damaged.|OK");
|
||
|
||
// A canvas with `layers` blank layers, as unsaved work.
|
||
void paint(EditorSession &session, int layers)
|
||
{
|
||
session.createDocument(8, 6);
|
||
for (int index = 0; index < layers; ++index)
|
||
session.addBlankLayer();
|
||
}
|
||
|
||
// A project of `layers` layers saved at `path`.
|
||
void store(const QString &path, int layers)
|
||
{
|
||
EditorSession session;
|
||
paint(session, layers);
|
||
ProjectStore::save(session.projectSnapshot().value(), path);
|
||
}
|
||
|
||
std::optional<bool> answered(const std::function<void(std::function<void(bool)>)> &call)
|
||
{
|
||
std::optional<bool> result;
|
||
call([&](bool value) { result = value; });
|
||
// An answer that never comes stays empty and fails.
|
||
return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) ? result : std::nullopt;
|
||
}
|
||
|
||
int layerCount(const EditorSession &session)
|
||
{
|
||
return int(session.document().value().layers.size());
|
||
}
|
||
|
||
// How often the store read, and the threads it used.
|
||
std::atomic<int> loads = 0;
|
||
std::atomic<QThread *> loadingThread = nullptr, savingThread = nullptr;
|
||
QtMessageHandler forward = nullptr;
|
||
void countLoads(QtMsgType type, const QMessageLogContext &context, const QString &message)
|
||
{
|
||
if (message.startsWith("loaded ")) {
|
||
loads += 1;
|
||
loadingThread = QThread::currentThread();
|
||
}
|
||
if (message.startsWith("saved "))
|
||
savingThread = QThread::currentThread();
|
||
forward(type, context, message);
|
||
}
|
||
}
|
||
|
||
class ProjectOpenTests : public QObject {
|
||
Q_OBJECT
|
||
private slots:
|
||
void init();
|
||
void cleanup();
|
||
void aSuppliedPathIsInstalledUnasked();
|
||
void withoutAPathAFolderIsAskedFor();
|
||
void aDamagedOrMissingProjectKeepsTheLiveDocument();
|
||
void unsavedWorkIsAskedAboutBeforeItGoes();
|
||
void aSaveInTheQuestionReloadsTheVeryProjectBeingOpened();
|
||
void onlyASaveOverTheOpenedPlaceReloads();
|
||
void theLastSignalOfAnOpenSeesItDone();
|
||
void theStoreWorksOffTheCallersThread();
|
||
void aTrailingSlashLeavesTheProjectItsName();
|
||
};
|
||
|
||
void ProjectOpenTests::init()
|
||
{
|
||
loads = 0;
|
||
forward = qInstallMessageHandler(countLoads);
|
||
}
|
||
|
||
void ProjectOpenTests::cleanup()
|
||
{
|
||
qInstallMessageHandler(forward);
|
||
}
|
||
|
||
void ProjectOpenTests::aSuppliedPathIsInstalledUnasked()
|
||
{
|
||
QTemporaryDir folder;
|
||
const QString path = folder.filePath("Three.comp");
|
||
store(path, 3);
|
||
EditorSession session;
|
||
ProjectController controller(session);
|
||
DialogDesk desk;
|
||
bool busyMeanwhile = false;
|
||
connect(&session, &EditorSession::changed, this, [&] { busyMeanwhile = busyMeanwhile || session.isProjectBusy(); });
|
||
QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true));
|
||
QVERIFY(desk.seen.isEmpty() && busyMeanwhile);
|
||
QCOMPARE(layerCount(session), 3);
|
||
QCOMPARE(session.projectPath(), std::optional(path));
|
||
QVERIFY(!session.isModified() && !session.isProjectBusy());
|
||
QCOMPARE(loads, 1);
|
||
// Saved work goes unasked as well.
|
||
const QString other = folder.filePath("One.comp");
|
||
store(other, 1);
|
||
QCOMPARE(answered([&](auto done) { controller.open(other, done); }), std::optional(true));
|
||
QVERIFY(desk.seen.isEmpty());
|
||
QCOMPARE(layerCount(session), 1);
|
||
QCOMPARE(session.projectPath(), std::optional(other));
|
||
// Each read's watcher goes once it has answered.
|
||
QTRY_VERIFY(controller.findChildren<QFutureWatcherBase *>().isEmpty());
|
||
}
|
||
|
||
void ProjectOpenTests::withoutAPathAFolderIsAskedFor()
|
||
{
|
||
QTemporaryDir folder;
|
||
const QString path = folder.filePath("Asked.comp");
|
||
store(path, 2);
|
||
EditorSession session;
|
||
ProjectController controller(session);
|
||
DialogDesk desk;
|
||
desk.note = [&] { return session.isProjectBusy() ? QStringLiteral("busy") : QStringLiteral("free"); };
|
||
// Typed with a slash at its end, as folders are.
|
||
desk.replies = {"<cancel>", path + "/"};
|
||
const QStringList before = described(session);
|
||
QCOMPARE(answered([&](auto done) { controller.open(std::nullopt, done); }), std::optional(false));
|
||
QCOMPARE(described(session), before);
|
||
QCOMPARE(loads, 0);
|
||
QCOMPARE(answered([&](auto done) { controller.open(std::nullopt, done); }), std::optional(true));
|
||
QCOMPARE(desk.seen, (QStringList{"panel|Open Project|open|folder|||busy", "panel|Open Project|open|folder|||busy"}));
|
||
QCOMPARE(layerCount(session), 2);
|
||
QCOMPARE(session.projectPath(), std::optional(path));
|
||
QVERIFY(!session.isProjectBusy());
|
||
}
|
||
|
||
void ProjectOpenTests::aDamagedOrMissingProjectKeepsTheLiveDocument()
|
||
{
|
||
QTemporaryDir folder;
|
||
const QString broken = folder.filePath("Broken.comp");
|
||
QVERIFY(QDir().mkpath(broken));
|
||
QFile manifest(broken + "/manifest.json");
|
||
QVERIFY(manifest.open(QIODevice::WriteOnly));
|
||
manifest.write("{ not a manifest");
|
||
manifest.close();
|
||
EditorSession session;
|
||
paint(session, 2);
|
||
ProjectController controller(session);
|
||
const QStringList before = described(session);
|
||
DialogDesk desk;
|
||
desk.note = [&] { return session.isProjectBusy() ? QStringLiteral("busy") : QStringLiteral("free"); };
|
||
desk.replies = {"OK", "OK"};
|
||
QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: This is not a valid OmaPhoto project, or its metadata is damaged.");
|
||
QCOMPARE(answered([&](auto done) { controller.open(broken, done); }), std::optional(false));
|
||
// Validation came first: the unsaved work was never in question.
|
||
QCOMPARE(desk.seen, (QStringList{damaged + "|busy"}));
|
||
QCOMPARE(described(session), before);
|
||
QVERIFY(session.isModified() && !session.isProjectBusy());
|
||
QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: This is not a valid OmaPhoto project, or its metadata is damaged.");
|
||
QCOMPARE(answered([&](auto done) { controller.open(folder.filePath("Nowhere.comp"), done); }), std::optional(false));
|
||
QCOMPARE(desk.seen.size(), 2);
|
||
QCOMPARE(described(session), before);
|
||
}
|
||
|
||
void ProjectOpenTests::unsavedWorkIsAskedAboutBeforeItGoes()
|
||
{
|
||
QTemporaryDir folder;
|
||
const QString incoming = folder.filePath("Incoming.comp"), kept = folder.filePath("Kept.comp");
|
||
store(incoming, 3);
|
||
EditorSession session;
|
||
paint(session, 1);
|
||
ProjectController controller(session);
|
||
DialogDesk desk;
|
||
desk.replies = {"Cancel", "Don’t Save"};
|
||
const QStringList before = described(session);
|
||
QCOMPARE(answered([&](auto done) { controller.open(incoming, done); }), std::optional(false));
|
||
QCOMPARE(described(session), before);
|
||
QCOMPARE(answered([&](auto done) { controller.open(incoming, done); }), std::optional(true));
|
||
QCOMPARE(desk.seen, (QStringList{question.arg("Untitled"), question.arg("Untitled")}));
|
||
QCOMPARE(layerCount(session), 3);
|
||
QVERIFY(!session.isModified());
|
||
// Save first: the work reaches the disk, then goes.
|
||
paint(session, 2);
|
||
session.setProjectPath(kept);
|
||
desk.replies = {"Save"};
|
||
QCOMPARE(answered([&](auto done) { controller.open(incoming, done); }), std::optional(true));
|
||
QCOMPARE(desk.seen.last(), question.arg("Kept.comp"));
|
||
QCOMPARE(int(ProjectStore::load(kept).manifest.layers.size()), 2);
|
||
QCOMPARE(layerCount(session), 3);
|
||
QCOMPARE(session.projectPath(), std::optional(incoming));
|
||
QVERIFY(!session.isModified() && !session.isProjectBusy());
|
||
}
|
||
|
||
void ProjectOpenTests::aSaveInTheQuestionReloadsTheVeryProjectBeingOpened()
|
||
{
|
||
QTemporaryDir folder;
|
||
const QString path = folder.filePath("Same.comp"), link = folder.filePath("Link.comp");
|
||
store(path, 1);
|
||
QVERIFY(QFile::link(path, link));
|
||
EditorSession session;
|
||
ProjectController controller(session);
|
||
QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true));
|
||
session.addBlankLayer();
|
||
QCOMPARE(layerCount(session), 2);
|
||
// Opened again through a link, and saved in the question.
|
||
DialogDesk desk;
|
||
desk.replies = {"Save"};
|
||
loads = 0;
|
||
QCOMPARE(answered([&](auto done) { controller.open(link, done); }), std::optional(true));
|
||
QCOMPARE(desk.seen, (QStringList{question.arg("Same.comp")}));
|
||
// What was read before the save is stale: read again.
|
||
QCOMPARE(loads, 2);
|
||
QCOMPARE(layerCount(session), 2);
|
||
QCOMPARE(session.projectPath(), std::optional(link));
|
||
QVERIFY(!session.isModified() && !session.isProjectBusy());
|
||
}
|
||
|
||
void ProjectOpenTests::onlyASaveOverTheOpenedPlaceReloads()
|
||
{
|
||
QTemporaryDir folder;
|
||
const QString first = folder.filePath("First.comp"), second = folder.filePath("Second.comp");
|
||
store(first, 1);
|
||
store(second, 3);
|
||
EditorSession session;
|
||
ProjectController controller(session);
|
||
QCOMPARE(answered([&](auto done) { controller.open(first, done); }), std::optional(true));
|
||
// The same place, nothing saved: one read is enough.
|
||
session.addBlankLayer();
|
||
DialogDesk desk;
|
||
desk.replies = {"Don’t Save", "Save"};
|
||
loads = 0;
|
||
QCOMPARE(answered([&](auto done) { controller.open(first, done); }), std::optional(true));
|
||
QCOMPARE(loads, 1);
|
||
QCOMPARE(layerCount(session), 1);
|
||
// Saved, but to another place than the one opened.
|
||
session.addBlankLayer();
|
||
loads = 0;
|
||
QCOMPARE(answered([&](auto done) { controller.open(second, done); }), std::optional(true));
|
||
QCOMPARE(loads, 1);
|
||
QCOMPARE(layerCount(session), 3);
|
||
QCOMPARE(int(ProjectStore::load(first).manifest.layers.size()), 2);
|
||
}
|
||
|
||
void ProjectOpenTests::theLastSignalOfAnOpenSeesItDone()
|
||
{
|
||
QTemporaryDir folder;
|
||
const QString path = folder.filePath("Seen.comp");
|
||
store(path, 2);
|
||
EditorSession session;
|
||
ProjectController controller(session);
|
||
QStringList seen;
|
||
connect(&session, &EditorSession::changed, this, [&] { seen = described(session); });
|
||
QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true));
|
||
QCOMPARE(seen, described(session));
|
||
QVERIFY(seen.contains("path " + path) && seen.contains("flags 0000"));
|
||
}
|
||
|
||
void ProjectOpenTests::theStoreWorksOffTheCallersThread()
|
||
{
|
||
QTemporaryDir folder;
|
||
const QString path = folder.filePath("Threads.comp");
|
||
store(path, 1);
|
||
EditorSession session;
|
||
ProjectController controller(session);
|
||
loadingThread = savingThread = nullptr;
|
||
QCOMPARE(answered([&](auto done) { controller.open(path, done); }), std::optional(true));
|
||
session.addBlankLayer();
|
||
QCOMPARE(answered([&](auto done) { controller.save(false, done); }), std::optional(true));
|
||
// The store logs from the thread that did the work.
|
||
QVERIFY(loadingThread.load() != nullptr && savingThread.load() != nullptr);
|
||
QVERIFY(loadingThread.load() != QThread::currentThread());
|
||
QVERIFY(savingThread.load() != QThread::currentThread());
|
||
QCOMPARE(controller.thread(), QThread::currentThread());
|
||
}
|
||
|
||
void ProjectOpenTests::aTrailingSlashLeavesTheProjectItsName()
|
||
{
|
||
QTemporaryDir folder;
|
||
const QString path = folder.filePath("Friendly.COMP");
|
||
store(path, 1);
|
||
EditorSession session;
|
||
ProjectController controller(session);
|
||
// A shell completes a folder's name with a slash.
|
||
QCOMPARE(answered([&](auto done) { controller.open(path + "/", done); }), std::optional(true));
|
||
QCOMPARE(session.projectPath(), std::optional(path));
|
||
session.addBlankLayer();
|
||
DialogDesk desk;
|
||
desk.replies = {"Cancel", "<cancel>"};
|
||
QCOMPARE(answered([&](auto done) { controller.confirmQuit(done); }), std::optional(false));
|
||
QCOMPARE(answered([&](auto done) { controller.save(true, done); }), std::optional(false));
|
||
QCOMPARE(desk.seen, (QStringList{question.arg("Friendly.COMP"), "panel|Save Project As|save|file|Friendly.COMP|comp"}));
|
||
// Only the end is trimmed: `link/..` keeps its meaning.
|
||
QVERIFY(QDir().mkpath(folder.filePath("remote/deep")));
|
||
QVERIFY(QFile::link(folder.filePath("remote/deep"), folder.filePath("link")));
|
||
store(folder.filePath("Other.comp"), 1);
|
||
store(folder.filePath("remote/Other.comp"), 3);
|
||
EditorSession linked;
|
||
ProjectController opener(linked);
|
||
const QString through = folder.filePath("link/../Other.comp");
|
||
QCOMPARE(answered([&](auto done) { opener.open(through + "//", done); }), std::optional(true));
|
||
QCOMPARE(layerCount(linked), 3);
|
||
QCOMPARE(linked.projectPath(), std::optional(through));
|
||
}
|
||
|
||
QTEST_MAIN(ProjectOpenTests)
|
||
#include "ProjectOpenTests.moc"
|