Files
OmaPhoto/tests/ProjectOpenTests.cpp
T

309 lines
13 KiB
C++
Raw Permalink 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 <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());
// A missing project is named.
QTest::ignoreMessage(QtWarningMsg, "Couldn’t open the project: The file “Nowhere.comp” couldn’t be opened because there is no such file.");
QCOMPARE(answered([&](auto done) { controller.open(folder.filePath("Nowhere.comp"), done); }), std::optional(false));
QCOMPARE(desk.seen.size(), 2);
QVERIFY2(desk.seen[1].startsWith("alert|2|Couldn’t open the project|The file “Nowhere.comp” couldn’t be opened because there is no such file.|"),
qPrintable(desk.seen[1]));
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"