#include "DialogDesk.h" #include "IO/ProjectController.h" #include "IO/ProjectStore.h" #include "SessionRecord.h" #include #include #include #include // 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 answered(const std::function)> &call) { std::optional 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 loads = 0; std::atomic 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().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 = {"", 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", ""}; 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"