Keep editing while a project saves; saves, reloads and opens queue behind one writer (12.13b)

This commit is contained in:
雷电芽衣 committed 2026-09-29 22:39:49 -04:00
1 parent 838d5b1459
commit d4c065fa77
11 files changed
+1027 -85

No files matched your search

+370
View File
@@ -0,0 +1,370 @@
#include "SaveHooks.h"
#include "UI/ColorPickerSheet.h"
// A write held mid-save: what waits, what goes beside.
class ProjectSaveOrderTests : public QObject {
Q_OBJECT
private slots:
void init();
void cleanup();
void aReopenReadsNothingWhileTheWriteIsHeld();
void aRevertAnsweredMidSaveLoadsWhatWeWrote();
void theGuardLastsThroughTheSavesDigest();
void canvasSizeWorksBesideAHeldWrite();
void imageSizeWorksBesideAHeldWrite();
void aDigestOutWhenOurSaveBeginsIsDropped();
void aFailedSaveKeepsAnExternalChangeInLine();
void aSecondSaveWaitsThroughTheFirstDigest();
void canvasSizeCommittedBeforeTheSaveLands();
void imageSizeCommittedAfterTheSaveLands();
void keepMineAfterASaveKeepsTheSavedDigest();
};
void ProjectSaveOrderTests::init()
{
resetHooks();
}
void ProjectSaveOrderTests::cleanup()
{
resetHooks();
}
void ProjectSaveOrderTests::aReopenReadsNothingWhileTheWriteIsHeld()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Latest saved version"));
holdSwap = true;
std::optional<bool> saved, reopened;
controller->save(false, [&](bool value) { saved = value; });
const int before = loads;
controller->open(path, [&](bool value) { reopened = value; });
QTest::qWait(400);
// Held mid-save: the open has not read the package.
QCOMPARE(loads.load(), before);
QVERIFY(!reopened.has_value());
holdSwap = false;
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value() && reopened.has_value(), 10000);
QCOMPARE(firstName(session), QString("Latest saved version"));
QVERIFY(!session.isModified());
}
void ProjectSaveOrderTests::aRevertAnsweredMidSaveLoadsWhatWeWrote()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
QWidget window;
window.show();
controller->window = &window;
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Ours"));
std::optional<bool> saved;
int loadsWhileHeld = -1;
DialogDesk desk;
desk.replies = {"Revert"};
// With the question up, we save; the write is held.
desk.note = [&] {
holdSwap = true;
controller->save(false, [&](bool value) { saved = value; });
const int before = loads;
QTimer::singleShot(400, [&, before] {
loadsWhileHeld = loads - before;
holdSwap = false;
});
return QString();
};
renameFirstLayerOnDisk(path, QStringLiteral("Theirs"));
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 10000);
QCOMPARE(loadsWhileHeld, 0);
QCOMPARE(firstName(session), QString("Ours"));
QVERIFY(!session.isModified());
QCOMPARE(controller->externalChanges.knownDigest.value(), ProjectDigest::compute(path));
}
void ProjectSaveOrderTests::theGuardLastsThroughTheSavesDigest()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Saved"));
// Just after the path is announced, the digest is out.
std::optional<bool> guarded;
QObject::connect(&session, &EditorSession::changed, [&] {
if (guarded.has_value() || session.isModified())
return;
QMetaObject::invokeMethod(controller.get(), [&] { guarded = controller->externalChanges.saving; }, Qt::QueuedConnection);
});
QVERIFY(answered([&](auto done) { controller->save(false, done); }));
QCOMPARE(guarded, std::optional<bool>(true));
QVERIFY(!controller->externalChanges.saving);
}
void ProjectSaveOrderTests::canvasSizeWorksBesideAHeldWrite()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
QWidget window;
window.show();
controller->window = &window;
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Captured"));
holdSwap = true;
std::optional<bool> saved;
controller->save(false, [&](bool value) { saved = value; });
// The sheet opens beside the held write, holding the tools.
bool resized = false;
controller->canvasSize([&] { resized = true; });
QTRY_VERIFY(window.findChild<QDialog *>());
QVERIFY(session.isProjectBusy());
holdSwap = false;
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
// Saved with the sheet still up: the tools stay held.
QVERIFY(session.isProjectBusy());
auto *width = window.findChild<QDialog *>()->findChild<PickerField *>(QStringLiteral("canvasWidth"));
width->setFocus();
width->selectAll();
QTest::keyClicks(width, QStringLiteral("20"));
QTest::keyClick(width, Qt::Key_Return);
QTRY_VERIFY_WITH_TIMEOUT(resized, 10000);
QCOMPARE(session.document().value().width, 20);
QCOMPARE(ProjectStore::load(path).manifest.width, qint64(8));
QVERIFY(session.isModified());
session.undo();
QVERIFY(!session.isModified());
QCOMPARE(session.document().value().width, 8);
}
void ProjectSaveOrderTests::imageSizeWorksBesideAHeldWrite()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
QWidget window;
window.show();
controller->window = &window;
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Captured"));
holdSwap = true;
std::optional<bool> saved;
controller->save(false, [&](bool value) { saved = value; });
bool resized = false;
controller->imageSize([&] { resized = true; });
QTRY_VERIFY(window.findChild<QDialog *>());
// Resized before the write lands: the capture stays.
auto *width = window.findChild<QDialog *>()->findChild<PickerField *>(QStringLiteral("imageWidth"));
width->setFocus();
width->selectAll();
QTest::keyClicks(width, QStringLiteral("16"));
QTest::keyClick(width, Qt::Key_Return);
QTRY_VERIFY_WITH_TIMEOUT(resized, 10000);
QCOMPARE(session.document().value().width, 16);
QCOMPARE(session.document().value().height, 12);
QVERIFY(!saved.has_value());
holdSwap = false;
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
QVERIFY(saved.value());
QCOMPARE(ProjectStore::load(path).manifest.width, qint64(8));
QVERIFY(session.isModified());
session.undo();
QVERIFY(!session.isModified());
}
void ProjectSaveOrderTests::aDigestOutWhenOurSaveBeginsIsDropped()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
// The check's digest waits at the images listing.
heldListing = QFile::encodeName(QFileInfo(path + QStringLiteral("/images")).canonicalFilePath());
holdListing = true;
renameFirstLayerOnDisk(path, QStringLiteral("Theirs"));
QTRY_VERIFY_WITH_TIMEOUT(listingsHeld > 0, 4000);
// Our save starts, held at its swap; the digest returns.
holdSwap = true;
std::optional<bool> saved;
controller->save(false, [&](bool value) { saved = value; });
QVERIFY(controller->externalChanges.saving);
holdListing = false;
QTest::qWait(400);
QVERIFY(!controller->externalChanges.checking);
holdSwap = false;
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
settle();
// What it read is ours now: no reload, nothing asked.
QCOMPARE(controller->externalChanges.reloadCount, 0);
QCOMPARE(firstName(session), QString("Base"));
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Base"));
QCOMPARE(controller->externalChanges.knownDigest.value(), ProjectDigest::compute(path));
}
void ProjectSaveOrderTests::keepMineAfterASaveKeepsTheSavedDigest()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
QWidget window;
window.show();
controller->window = &window;
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Ours"));
DialogDesk desk;
desk.replies = {"Keep Mine"};
// With the question up, our save lands first.
desk.note = [&] {
std::optional<bool> saved;
controller->save(false, [&](bool value) { saved = value; });
if (!QTest::qWaitFor([&] { return saved.has_value(); }, 10000))
return QStringLiteral("saved=never");
return QStringLiteral("saved=%1").arg(saved.value_or(false));
};
renameFirstLayerOnDisk(path, QStringLiteral("Theirs"));
QTRY_COMPARE_WITH_TIMEOUT(desk.seen.size(), 1, 6000);
QVERIFY2(desk.seen[0].endsWith("|saved=1"), qPrintable(desk.seen[0]));
settle();
QCOMPARE(controller->externalChanges.knownDigest.value(), ProjectDigest::compute(path));
// A touch of our own package changes nothing.
const QString manifest = path + QStringLiteral("/manifest.json");
rewrite(manifest, contents(manifest));
settle();
QCOMPARE(controller->externalChanges.reloadCount, 0);
QCOMPARE(desk.seen.size(), 1);
QCOMPARE(firstName(session), QString("Ours"));
}
void ProjectSaveOrderTests::aFailedSaveKeepsAnExternalChangeInLine()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
heldListing = QFile::encodeName(QFileInfo(path + QStringLiteral("/images")).canonicalFilePath());
holdListing = true;
renameFirstLayerOnDisk(path, QStringLiteral("External version"));
QTRY_VERIFY_WITH_TIMEOUT(listingsHeld > 0, 4000);
// Our save fails; the digest returns under its alert.
QVERIFY(QFile::setPermissions(root.path(), QFileDevice::ReadOwner | QFileDevice::ExeOwner));
DialogDesk desk;
desk.replies = {"OK"};
desk.note = [&] {
holdListing = false;
QTest::qWait(400);
return QStringLiteral("saving=%1").arg(controller->externalChanges.saving);
};
std::optional<bool> saved;
controller->save(false, [&](bool value) { saved = value; });
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
QVERIFY(!saved.value());
QVERIFY2(desk.seen.value(0).endsWith("|saving=1"), qPrintable(desk.seen.value(0)));
QFile::setPermissions(root.path(), QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
// After the failure, the change it saw is taken up.
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 6000);
QCOMPARE(firstName(session), QString("External version"));
QVERIFY(!controller->externalChanges.pending);
}
void ProjectSaveOrderTests::aSecondSaveWaitsThroughTheFirstDigest()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
const QUuid base = session.document().value().layers[0].id;
// The first save's digest waits at the images listing.
heldListing = QFile::encodeName(QFileInfo(path + QStringLiteral("/images")).canonicalFilePath());
holdListing = true;
session.renameLayer(base, QStringLiteral("First"));
std::optional<bool> first, second;
controller->save(false, [&](bool value) { first = value; });
QTRY_VERIFY_WITH_TIMEOUT(swaps == 1 && listingsHeld > 0, 6000);
session.renameLayer(base, QStringLiteral("Second"));
controller->save(false, [&](bool value) { second = value; });
QTest::qWait(400);
QCOMPARE(swaps.load(), 1);
QVERIFY(!first.has_value() && !second.has_value());
holdListing = false;
QTRY_VERIFY_WITH_TIMEOUT(first.has_value() && second.has_value(), 10000);
QVERIFY(first.value() && second.value());
QCOMPARE(swaps.load(), 2);
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Second"));
}
void ProjectSaveOrderTests::canvasSizeCommittedBeforeTheSaveLands()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
QWidget window;
window.show();
controller->window = &window;
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Captured"));
holdSwap = true;
std::optional<bool> saved;
controller->save(false, [&](bool value) { saved = value; });
bool resized = false;
controller->canvasSize([&] { resized = true; });
QTRY_VERIFY(window.findChild<QDialog *>());
auto *width = window.findChild<QDialog *>()->findChild<PickerField *>(QStringLiteral("canvasWidth"));
width->setFocus();
width->selectAll();
QTest::keyClicks(width, QStringLiteral("16"));
QTest::keyClick(width, Qt::Key_Return);
QTRY_VERIFY_WITH_TIMEOUT(resized, 10000);
QCOMPARE(session.document().value().width, 16);
QVERIFY(!saved.has_value());
holdSwap = false;
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
QVERIFY(saved.value());
QCOMPARE(ProjectStore::load(path).manifest.width, qint64(8));
QVERIFY(session.isModified());
session.undo();
QVERIFY(!session.isModified());
QCOMPARE(session.document().value().width, 8);
}
void ProjectSaveOrderTests::imageSizeCommittedAfterTheSaveLands()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
QWidget window;
window.show();
controller->window = &window;
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Captured"));
holdSwap = true;
std::optional<bool> saved;
controller->save(false, [&](bool value) { saved = value; });
bool resized = false;
controller->imageSize([&] { resized = true; });
QTRY_VERIFY(window.findChild<QDialog *>());
holdSwap = false;
// Saved with the sheet still up: the tools stay held.
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value(), 10000);
QVERIFY(saved.value());
QVERIFY(session.isProjectBusy());
QVERIFY(!session.isModified());
auto *width = window.findChild<QDialog *>()->findChild<PickerField *>(QStringLiteral("imageWidth"));
width->setFocus();
width->selectAll();
QTest::keyClicks(width, QStringLiteral("16"));
QTest::keyClick(width, Qt::Key_Return);
QTRY_VERIFY_WITH_TIMEOUT(resized, 10000);
QCOMPARE(session.document().value().width, 16);
QCOMPARE(ProjectStore::load(path).manifest.width, qint64(8));
QVERIFY(session.isModified());
session.undo();
QVERIFY(!session.isModified());
QCOMPARE(session.document().value().width, 8);
}
QTEST_MAIN(ProjectSaveOrderTests)
#include "ProjectSaveOrderTests.moc"
+239
View File
@@ -0,0 +1,239 @@
#include "SaveHooks.h"
// Saves queued behind a write: one writer at a time.
class ProjectSaveQueueTests : public QObject {
Q_OBJECT
private slots:
void init();
void cleanup();
void threeSavesWriteOneAfterAnother();
void aSaveAskedAsTheToolsFreeWaitsItsTurn();
void aQuitWaitsForTheWriteBeforeAsking();
void aQueuedSaveWithoutADocumentSavesNothing();
void aFailedWriteHoldsItsWaitersUntilAnswered();
void reopeningWhileSavingReadsTheSavedPackage();
void closingATabWaitsForItsWrite();
void aControllerGoneMidWriteLeavesNoCallback();
void anExportDuringAWriteSucceedsBeside();
};
void ProjectSaveQueueTests::init()
{
resetHooks();
}
void ProjectSaveQueueTests::cleanup()
{
resetHooks();
}
void ProjectSaveQueueTests::threeSavesWriteOneAfterAnother()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
const QUuid base = session.document().value().layers[0].id;
QStringList order;
for (const QString &name : {QStringLiteral("A"), QStringLiteral("B"), QStringLiteral("C")}) {
session.renameLayer(base, name);
controller->save(false, [&, name](bool saved) {
order << QStringLiteral("%1=%2 disk=%3").arg(name).arg(saved).arg(ProjectStore::load(path).manifest.layers[0].name);
});
}
session.renameLayer(base, QStringLiteral("Last"));
QTRY_COMPARE_WITH_TIMEOUT(order.size(), 3, 10000);
// A captured itself; the rest waited and captured the latest.
QCOMPARE(order, (QStringList{"A=1 disk=A", "B=1 disk=Last", "C=1 disk=Last"}));
QCOMPARE(mostAtOnce.load(), 1);
QVERIFY(!session.isModified());
QVERIFY(!controller->externalChanges.saving);
}
void ProjectSaveQueueTests::aSaveAskedAsTheToolsFreeWaitsItsTurn()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("First"));
// The moment the tools go free, an observer saves again.
std::optional<bool> first, second;
bool asked = false, wasBusy = false, reserved = false;
QObject::connect(&session, &EditorSession::changed, [&] {
const bool freed = wasBusy && !session.isProjectBusy();
wasBusy = session.isProjectBusy();
if (asked || !freed)
return;
asked = true;
reserved = controller->externalChanges.saving;
controller->save(false, [&](bool saved) { second = saved; });
});
controller->save(false, [&](bool saved) { first = saved; });
QTRY_VERIFY_WITH_TIMEOUT(first.has_value() && second.has_value(), 10000);
QVERIFY(asked);
QVERIFY(reserved);
QVERIFY(first.value() && second.value());
QCOMPARE(mostAtOnce.load(), 1);
}
void ProjectSaveQueueTests::aQuitWaitsForTheWriteBeforeAsking()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
QWidget window;
window.show();
controller->window = &window;
const QUuid base = session.document().value().layers[0].id;
QStringList order;
session.renameLayer(base, QStringLiteral("One"));
controller->save(false, [&](bool saved) { order << QStringLiteral("one=%1").arg(saved); });
session.renameLayer(base, QStringLiteral("Two"));
// The quit asks only once the write is whole.
DialogDesk desk;
desk.replies = {"Don’t Save"};
desk.note = [&] { return QStringLiteral("disk=%1 saving=%2").arg(ProjectStore::load(path).manifest.layers[0].name).arg(controller->externalChanges.saving); };
controller->confirmQuit([&](bool quits) { order << QStringLiteral("quit=%1").arg(quits); });
QTRY_COMPARE_WITH_TIMEOUT(order.size(), 2, 10000);
QCOMPARE(order, (QStringList{"one=1", "quit=1"}));
QCOMPARE(desk.seen.size(), 1);
QVERIFY2(desk.seen[0].endsWith("|disk=One saving=0"), qPrintable(desk.seen[0]));
}
void ProjectSaveQueueTests::aQueuedSaveWithoutADocumentSavesNothing()
{
QTemporaryDir root;
EditorSession session;
ProjectController controller(session);
session.createDocument(8, 6);
session.addBlankLayer();
const QString path = root.filePath(QStringLiteral("New.comp"));
DialogDesk desk;
desk.replies = {path};
std::optional<bool> first, second;
controller.save(false, [&](bool saved) { first = saved; });
QTRY_VERIFY_WITH_TIMEOUT(controller.externalChanges.saving, 4000);
controller.save(false, [&](bool saved) { second = saved; });
// Undo takes the canvas back while the first write runs.
while (session.document() && session.canUndo())
session.undo();
QVERIFY(!session.document());
QTRY_VERIFY_WITH_TIMEOUT(first.has_value() && second.has_value(), 10000);
QVERIFY(first.value());
QVERIFY(!second.value());
QVERIFY(!session.isProjectBusy());
QCOMPARE(desk.seen.size(), 1);
}
void ProjectSaveQueueTests::aFailedWriteHoldsItsWaitersUntilAnswered()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
const QString locked = root.filePath(QStringLiteral("Locked"));
QVERIFY(QDir().mkpath(locked));
QVERIFY(QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::ExeOwner));
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved"));
std::optional<bool> failed, again, quit;
DialogDesk desk;
desk.replies = {locked + QStringLiteral("/Copy.comp"), "OK", "Don’t Save"};
// With the failure's alert up, a save and quit queue.
desk.note = [&] {
if (desk.seen.size() != 1)
return QString();
controller->save(false, [&](bool saved) { again = saved; });
controller->confirmQuit([&](bool quits) { quit = quits; });
QTest::qWait(300);
return QStringLiteral("again=%1 quit=%2").arg(again.has_value()).arg(quit.has_value());
};
controller->save(true, [&](bool saved) { failed = saved; });
QTRY_VERIFY_WITH_TIMEOUT(failed.has_value() && again.has_value() && quit.has_value(), 10000);
QVERIFY2(desk.seen.value(1).endsWith("|again=0 quit=0"), qPrintable(desk.seen.value(1)));
QVERIFY(!failed.value());
// The quit took the tools first: the save is refused.
QVERIFY(!again.value());
QVERIFY(quit.value());
QCOMPARE(desk.seen.size(), 3);
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Base"));
QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
}
void ProjectSaveQueueTests::reopeningWhileSavingReadsTheSavedPackage()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Newest saved version"));
std::optional<bool> saved, reopened;
controller->save(false, [&](bool value) { saved = value; });
controller->open(path, [&](bool value) { reopened = value; });
QTRY_VERIFY_WITH_TIMEOUT(saved.has_value() && reopened.has_value(), 10000);
QVERIFY(saved.value() && reopened.value());
QCOMPARE(firstName(session), QString("Newest saved version"));
QVERIFY(!session.isModified());
}
void ProjectSaveQueueTests::closingATabWaitsForItsWrite()
{
QTemporaryDir root;
const QString path = savedProject(root);
ProjectWorkspace workspace;
QVERIFY(answered([&](auto done) { workspace.open(path, done); }));
const std::shared_ptr<ProjectTab> tab = workspace.tabs().back();
tab->session.renameLayer(tab->session.document().value().layers[0].id, QStringLiteral("Written"));
std::optional<bool> saved;
tab->controller.save(false, [&](bool value) { saved = value; });
QVERIFY(tab->controller.externalChanges.saving);
bool closed = false;
workspace.close(tab->id, [&] { closed = true; });
// The tab stays until its package is whole.
QTRY_VERIFY_WITH_TIMEOUT(closed, 10000);
QVERIFY(saved.value());
QVERIFY(std::none_of(workspace.tabs().begin(), workspace.tabs().end(), [&](const auto &each) { return each->id == tab->id; }));
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Written"));
}
void ProjectSaveQueueTests::aControllerGoneMidWriteLeavesNoCallback()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
auto controller = opened(session, path);
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Written"));
bool first = false, second = false;
controller->save(false, [&](bool) { first = true; });
controller->save(false, [&](bool) { second = true; });
controller.reset();
QTest::qWait(1500);
QVERIFY(!first && !second);
// The worker finished its package all the same.
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Written"));
}
void ProjectSaveQueueTests::anExportDuringAWriteSucceedsBeside()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Written"));
std::optional<bool> saved;
controller->save(false, [&](bool value) { saved = value; });
// The tools are free: an export starts beside the write.
const QString png = root.filePath(QStringLiteral("Picture.png"));
DialogDesk desk;
desk.replies = {png};
bool exported = false;
controller->exportPNG([&] { exported = true; });
QTRY_VERIFY_WITH_TIMEOUT(exported && saved.has_value(), 10000);
QVERIFY(saved.value());
QVERIFY(QFile::exists(png));
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Written"));
}
QTEST_MAIN(ProjectSaveQueueTests)
#include "ProjectSaveQueueTests.moc"
+144
View File
@@ -0,0 +1,144 @@
#include "ExternalChangeFixtures.h"
// Swift's 940e188: editing goes on while a save writes.
class ProjectSaveWhileEditingTests : public QObject {
Q_OBJECT
private slots:
void editsMadeWhileWritingStayUnsaved();
void aSecondSaveWaitsForTheFirst();
void aNewCanvasWaitsForTheWriteBeforeAsking();
void onlyTheCaptureAndPanelHoldTheTools();
void aFailedWriteLeavesTheEditUnsaved();
void theLastSignalShowsTheSavedState();
};
void ProjectSaveWhileEditingTests::editsMadeWhileWritingStayUnsaved()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
const QUuid base = session.document().value().layers[0].id;
session.renameLayer(base, QStringLiteral("Captured"));
std::optional<bool> saved;
controller->save(false, [&](bool value) { saved = value; });
// Captured: the tools are free while it writes.
QVERIFY(!session.isProjectBusy());
QVERIFY(controller->externalChanges.saving);
session.renameLayer(base, QStringLiteral("After the capture"));
QTRY_VERIFY(saved.has_value());
QVERIFY(saved.value());
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Captured"));
QVERIFY(session.isModified());
// Undoing back to the saved version is unmodified again.
session.undo();
QCOMPARE(firstName(session), QString("Captured"));
QVERIFY(!session.isModified());
}
void ProjectSaveWhileEditingTests::aSecondSaveWaitsForTheFirst()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
const QUuid base = session.document().value().layers[0].id;
session.renameLayer(base, QStringLiteral("First"));
QStringList order;
controller->save(false, [&](bool saved) { order << QStringLiteral("first=%1").arg(saved); });
session.renameLayer(base, QStringLiteral("Second"));
controller->save(false, [&](bool saved) {
order << QStringLiteral("second=%1 name=%2").arg(saved).arg(ProjectStore::load(path).manifest.layers[0].name);
});
// Waiting, the second save holds nothing.
QVERIFY(!session.isProjectBusy());
QTRY_COMPARE(order.size(), 2);
QCOMPARE(order, (QStringList{"first=1", "second=1 name=Second"}));
QVERIFY(!session.isModified());
}
void ProjectSaveWhileEditingTests::aNewCanvasWaitsForTheWriteBeforeAsking()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
QWidget window;
window.show();
controller->window = &window;
const QUuid base = session.document().value().layers[0].id;
session.renameLayer(base, QStringLiteral("Written"));
DialogDesk desk;
desk.replies = {"Don’t Save"};
desk.note = [&] { return QStringLiteral("saving=%1").arg(controller->externalChanges.saving); };
std::optional<bool> saved;
controller->save(false, [&](bool value) { saved = value; });
session.renameLayer(base, QStringLiteral("Unsaved"));
controller->newCanvas();
// The question comes once the file is whole.
QTRY_VERIFY(!session.document());
QCOMPARE(desk.seen.size(), 1);
QVERIFY2(desk.seen[0].startsWith("alert|2|Save changes to Watched.comp?") && desk.seen[0].endsWith("|saving=0"), qPrintable(desk.seen[0]));
QVERIFY(saved.value());
QCOMPARE(ProjectStore::load(path).manifest.layers[0].name, QString("Written"));
}
void ProjectSaveWhileEditingTests::onlyTheCaptureAndPanelHoldTheTools()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
DialogDesk desk;
const QString copy = root.filePath(QStringLiteral("Copy.comp"));
desk.replies = {copy};
desk.note = [&] { return session.isProjectBusy() ? QStringLiteral("busy") : QStringLiteral("free"); };
std::optional<bool> saved;
bool busyWhileWriting = false;
QObject::connect(&session, &EditorSession::changed, [&] {
busyWhileWriting = busyWhileWriting || (controller->externalChanges.saving && session.isProjectBusy());
});
controller->save(true, [&](bool value) { saved = value; });
QTRY_VERIFY(saved.has_value());
QVERIFY(saved.value());
QVERIFY2(desk.seen.value(0).endsWith("|busy"), qPrintable(desk.seen.value(0)));
QVERIFY(!busyWhileWriting);
QCOMPARE(session.projectPath().value(), copy);
}
void ProjectSaveWhileEditingTests::aFailedWriteLeavesTheEditUnsaved()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
const QString locked = root.filePath(QStringLiteral("Locked"));
QVERIFY(QDir().mkpath(locked));
QVERIFY(QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::ExeOwner));
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved"));
DialogDesk desk;
desk.replies = {locked + QStringLiteral("/Copy.comp"), "OK"};
QVERIFY(!answered([&](auto done) { controller->save(true, done); }));
QVERIFY(session.isModified());
QCOMPARE(session.projectPath().value(), path);
// Nothing waits on a write that failed.
QVERIFY(answered([&](auto done) { controller->save(false, done); }));
QVERIFY(!session.isModified());
QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
}
void ProjectSaveWhileEditingTests::theLastSignalShowsTheSavedState()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Saved"));
std::optional<bool> modifiedAtLastSignal;
QObject::connect(&session, &EditorSession::changed, [&] { modifiedAtLastSignal = session.isModified(); });
QVERIFY(answered([&](auto done) { controller->save(false, done); }));
QCOMPARE(modifiedAtLastSignal, std::optional<bool>(false));
}
QTEST_MAIN(ProjectSaveWhileEditingTests)
#include "ProjectSaveWhileEditingTests.moc"
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include "ExternalChangeFixtures.h"
#include <QDialog>
#include <atomic>
#include <dirent.h>
#include <dlfcn.h>
#include <sys/syscall.h>
#include <unistd.h>
// Shared by the save tests: libc calls held and counted.
namespace {
// Each sync is slow, so overlapping writes show.
std::atomic<int> syncing = 0;
std::atomic<int> mostAtOnce = 0;
std::atomic<int> slowness = 0;
// While set, a package swap waits: a write held mid-save.
std::atomic<bool> holdSwap = false;
std::atomic<int> swaps = 0;
// While it names a folder, listing that folder waits.
QByteArray heldListing;
std::atomic<bool> holdListing = false;
std::atomic<int> listingsHeld = 0;
// Loads the store finished, counted from its log.
std::atomic<int> loads = 0;
QtMessageHandler forward = nullptr;
void countLoads(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (message.startsWith(QStringLiteral("loaded ")))
loads += 1;
forward(type, context, message);
}
// Every hold let go and every count cleared.
void resetHooks()
{
mostAtOnce = 0;
slowness = 20'000;
holdSwap = false;
swaps = 0;
holdListing = false;
listingsHeld = 0;
loads = 0;
if (!forward)
forward = qInstallMessageHandler(countLoads);
}
bool lists(DIR *directory, const QByteArray &folder)
{
char path[4096];
const QByteArray link = "/proc/self/fd/" + QByteArray::number(dirfd(directory));
const ssize_t size = readlink(link.constData(), path, sizeof path - 1);
return size > 0 && QByteArray(path, int(size)) == folder;
}
template <typename Entry> Entry *held(DIR *directory, Entry *entry)
{
if (!entry && holdListing && lists(directory, heldListing)) {
listingsHeld += 1;
while (holdListing)
usleep(1000);
}
return entry;
}
}
// These take libc's place in the test executable.
extern "C" struct dirent *readdir(DIR *directory)
{
static const auto real = reinterpret_cast<struct dirent *(*)(DIR *)>(dlsym(RTLD_NEXT, "readdir"));
return held(directory, real(directory));
}
extern "C" struct dirent64 *readdir64(DIR *directory)
{
static const auto real = reinterpret_cast<struct dirent64 *(*)(DIR *)>(dlsym(RTLD_NEXT, "readdir64"));
return held(directory, real(directory));
}
extern "C" int renameat2(int oldDirectory, const char *from, int newDirectory, const char *to, unsigned int flags)
{
while (holdSwap)
usleep(1000);
swaps += 1;
return int(syscall(SYS_renameat2, oldDirectory, from, newDirectory, to, flags));
}
extern "C" int fsync(int descriptor)
{
const int now = ++syncing;
int seen = mostAtOnce;
while (now > seen && !mostAtOnce.compare_exchange_weak(seen, now)) {
}
usleep(useconds_t(slowness.load()));
const int result = int(syscall(SYS_fsync, descriptor));
--syncing;
return result;
}
+3
View File
@@ -154,6 +154,9 @@ compositor_test(OmarchyThemeTests)
compositor_test(ProjectControllerTests)
compositor_test(ExternalChangeTests)
compositor_test(ExternalChangeGateTests)
compositor_test(ProjectSaveWhileEditingTests)
compositor_test(ProjectSaveQueueTests)
compositor_test(ProjectSaveOrderTests)
compositor_test(ProjectWatcherTests)
compositor_test(ProjectDigestRaceTests)
compositor_test(ProjectCopyTests)