Reload a project changed on disk, fingerprinted by its manifest and image sizes (12.13a)

This commit is contained in:
雷电芽衣 committed 2026-09-29 20:13:20 -04:00
1 parent aa253d9715
commit 838d5b1459
20 files changed
+1388 -8

No files matched your search

+338
View File
@@ -0,0 +1,338 @@
#include "ExternalChangeFixtures.h"
#include <QFutureWatcher>
// A project something else writes while it is open.
class ExternalChangeTests : public QObject {
Q_OBJECT
private slots:
void digestFollowsContentNotMetadata();
void reloadKeepsViewportSelectionAndFoldersButNotHistory();
void writingThePackageElsewhereReloadsTheOpenProject();
void ourOwnSaveAndMetadataTouchesDoNotReload();
void halfWrittenPackagesAreIgnoredUntilTheyLoad();
void unsavedWorkIsNeverReplacedWithoutAsking();
void unsavedWorkAsksToRevertOrKeep();
void aBackgroundTabAsksWhenItComesToTheFront();
void aBusyProjectWaitsThenReloads();
void closingOrANewCanvasStopsTheWatch();
void aNewCanvasDropsAWaitingRecheck();
void oneCheckRunsAtATime();
void aCheckWithoutAProjectOrDuringOurSaveEnds();
};
void ExternalChangeTests::digestFollowsContentNotMetadata()
{
QTemporaryDir root;
const QString path = savedProject(root);
const QString manifest = path + QStringLiteral("/manifest.json");
const QByteArray before = ProjectDigest::compute(path);
// Touched, and rewritten with the same bytes: sync clients.
QFile touched(manifest);
QVERIFY(touched.open(QIODevice::ReadWrite));
QVERIFY(touched.setFileTime(QDateTime::currentDateTime().addSecs(60), QFileDevice::FileModificationTime));
touched.close();
rewrite(manifest, contents(manifest));
QCOMPARE(ProjectDigest::compute(path), before);
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
const QByteArray renamed = ProjectDigest::compute(path);
QVERIFY(renamed != before);
// Another image's bytes under a layer's name.
const QDir images(path + QStringLiteral("/images"));
const QString png = images.entryList({QStringLiteral("*.png")}).value(0);
rewrite(images.filePath(png), contents(images.filePath(png)) + QByteArray(64, '\0'));
QVERIFY(ProjectDigest::compute(path) != renamed);
// No manifest, no digest.
QVERIFY(QFile::remove(manifest));
QVERIFY_THROWS_EXCEPTION(std::runtime_error, ProjectDigest::compute(path));
}
void ExternalChangeTests::reloadKeepsViewportSelectionAndFoldersButNotHistory()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
session.installProject(ProjectStore::load(path), path);
session.viewport.resize(QSizeF(800, 600), 1, session.document().value().size());
session.zoom(3);
const QUuid base = session.document().value().layers[0].id;
const QUuid blank = session.document().value().layers[1].id;
session.selectLayer(blank);
session.extendSelection(base);
QCOMPARE(session.activeLayerID().value(), base);
session.renameLayer(base, QStringLiteral("Edited here"));
QVERIFY(session.isModified() && session.canUndo());
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
session.reloadProject(ProjectStore::load(path));
QCOMPARE(firstName(session), QString("Renamed elsewhere"));
QCOMPARE(session.viewport.zoom(), 3.0);
QCOMPARE(session.activeLayerID().value(), base);
QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{base, blank}));
QVERIFY(!session.isModified());
QVERIFY(!session.canUndo());
QCOMPARE(session.projectPath().value(), path);
// A collapsed folder stays collapsed while it exists.
session.groupSelectedLayers();
const QUuid folder = session.activeLayerID().value();
session.toggleGroupExpansion(folder);
QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{folder});
const ProjectSnapshot folded = session.projectSnapshot().value();
session.reloadProject(folded);
QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{folder});
session.reloadProject(ProjectStore::load(path));
QVERIFY(session.collapsedGroupIDs().isEmpty());
QCOMPARE(session.activeLayerID().value(), ProjectStore::load(path).manifest.activeLayerID.value());
// A layer gone from disk: the loaded active one stands.
ProjectSnapshot fewer = ProjectStore::load(path);
fewer.manifest.layers.erase(fewer.manifest.layers.begin());
fewer.images.erase(base);
fewer.manifest.activeLayerID = blank;
session.reloadProject(fewer);
QCOMPARE(session.activeLayerID().value(), blank);
QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{blank});
}
void ExternalChangeTests::writingThePackageElsewhereReloadsTheOpenProject()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
// The reload holds the project busy, then frees it.
bool busyMeanwhile = false;
QObject::connect(&session, &EditorSession::changed, [&] { busyMeanwhile = busyMeanwhile || session.isProjectBusy(); });
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000);
QCOMPARE(firstName(session), QString("Renamed elsewhere"));
QVERIFY(!session.isModified());
QVERIFY(busyMeanwhile);
QVERIFY(!session.isProjectBusy());
// Every digest's watcher is gone once it answered.
QTRY_VERIFY(controller->findChildren<QFutureWatcherBase *>().isEmpty());
// The manifest rewritten in place, as a script would.
const QString manifest = path + QStringLiteral("/manifest.json");
rewrite(manifest, contents(manifest).replace("Renamed elsewhere", "Renamed again"));
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 2, 4000);
QCOMPARE(firstName(session), QString("Renamed again"));
}
void ExternalChangeTests::ourOwnSaveAndMetadataTouchesDoNotReload()
{
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 by us"));
QVERIFY(answered([&](auto done) { controller->save(false, done); }));
settle();
QCOMPARE(controller->externalChanges.reloadCount, 0);
QCOMPARE(firstName(session), QString("Saved by us"));
QVERIFY(!session.isModified());
const QString manifest = path + QStringLiteral("/manifest.json");
rewrite(manifest, contents(manifest));
settle();
QCOMPARE(controller->externalChanges.reloadCount, 0);
// Our save swapped the package in; the watch follows it.
renameFirstLayerOnDisk(path, QStringLiteral("Renamed after our save"));
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000);
QCOMPARE(firstName(session), QString("Renamed after our save"));
}
void ExternalChangeTests::halfWrittenPackagesAreIgnoredUntilTheyLoad()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
const QString manifest = path + QStringLiteral("/manifest.json");
const QByteArray good = contents(manifest);
rewrite(manifest, "{ \"format\": \"com.compositor.project\", \"version\": 9, \"layers\": [");
settle();
QCOMPARE(controller->externalChanges.reloadCount, 0);
QCOMPARE(session.document().value().layers.size(), size_t(2));
QVERIFY(!session.isProjectBusy());
rewrite(manifest, QByteArray(good).replace("\"Base\"", "\"Finished\""));
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000);
QCOMPARE(firstName(session), QString("Finished"));
}
void ExternalChangeTests::unsavedWorkIsNeverReplacedWithoutAsking()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved here"));
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
settle();
// No window to ask in: the question waits.
QCOMPARE(controller->externalChanges.reloadCount, 0);
QVERIFY(controller->externalChanges.pending);
QCOMPARE(firstName(session), QString("Unsaved here"));
QVERIFY(session.isModified());
}
void ExternalChangeTests::unsavedWorkAsksToRevertOrKeep()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
QWidget window;
window.show();
controller->window = &window;
DialogDesk desk;
desk.replies = {"Keep Mine", "Revert"};
session.renameLayer(session.document().value().layers[0].id, QStringLiteral("Unsaved here"));
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
QTRY_COMPARE_WITH_TIMEOUT(desk.seen.size(), 1, 4000);
QCOMPARE(desk.seen[0], QString("alert|2|“Watched.comp” was changed on disk.|Another app changed this project. You can revert to the version on "
"disk, losing your unsaved changes, or keep what you have.|Revert,Keep Mine"));
settle();
// Kept: the same package on disk asks nothing again.
QCOMPARE(firstName(session), QString("Unsaved here"));
QVERIFY(!controller->externalChanges.pending);
QCOMPARE(controller->externalChanges.reloadCount, 0);
QTRY_VERIFY(!window.findChild<QMessageBox *>());
const QString manifest = path + QStringLiteral("/manifest.json");
rewrite(manifest, contents(manifest));
settle();
QCOMPARE(desk.seen.size(), 1);
renameFirstLayerOnDisk(path, QStringLiteral("Renamed twice"));
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000);
QCOMPARE(desk.seen.size(), 2);
QCOMPARE(firstName(session), QString("Renamed twice"));
QVERIFY(!session.isModified());
}
void ExternalChangeTests::aBackgroundTabAsksWhenItComesToTheFront()
{
QTemporaryDir root;
const QString path = savedProject(root);
ProjectWorkspace workspace;
QWidget window;
window.show();
workspace.window = &window;
QVERIFY(answered([&](auto done) { workspace.open(path, done); }));
const std::shared_ptr<ProjectTab> watched = workspace.tabs().back();
watched->session.renameLayer(watched->session.document().value().layers[0].id, QStringLiteral("Unsaved here"));
workspace.newCanvas();
QVERIFY(&workspace.current() != watched.get());
DialogDesk desk;
desk.replies = {"Revert"};
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
settle();
QVERIFY(desk.seen.isEmpty());
QVERIFY(watched->controller.externalChanges.pending);
workspace.select(watched->id);
QTRY_COMPARE_WITH_TIMEOUT(watched->controller.externalChanges.reloadCount, 1, 4000);
QCOMPARE(desk.seen.size(), 1);
QCOMPARE(firstName(watched->session), QString("Renamed elsewhere"));
}
void ExternalChangeTests::aBusyProjectWaitsThenReloads()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
session.setIsProjectBusy(true);
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
settle();
QCOMPARE(controller->externalChanges.reloadCount, 0);
QVERIFY(controller->externalChanges.pending);
QVERIFY(controller->externalChanges.recheckAttempt > 0);
session.setIsProjectBusy(false);
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 8000);
QCOMPARE(firstName(session), QString("Renamed elsewhere"));
QCOMPARE(controller->externalChanges.recheckAttempt, 0);
}
void ExternalChangeTests::closingOrANewCanvasStopsTheWatch()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
QCOMPARE(controller->externalChanges.watcher->path, path);
QVERIFY(controller->externalChanges.knownDigest.has_value());
controller->newCanvas();
QTRY_VERIFY(!session.document());
QVERIFY(!controller->externalChanges.watcher);
QVERIFY(!controller->externalChanges.knownDigest);
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
settle();
QCOMPARE(controller->externalChanges.reloadCount, 0);
// Opened again and closed: the watch goes with the window.
const auto reopened = opened(session, path);
QWidget closing;
closing.show();
reopened->close(&closing);
QTRY_VERIFY(!session.document());
QVERIFY(!reopened->externalChanges.watcher);
}
void ExternalChangeTests::aNewCanvasDropsAWaitingRecheck()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
// An open transform holds the check back on a timer.
session.selectTool(NavigationTool::move);
session.selectLayer(session.document().value().layers[0].id);
session.beginTransform();
QVERIFY(session.transformEdit().has_value());
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
QTRY_VERIFY_WITH_TIMEOUT(controller->externalChanges.recheck->isActive(), 4000);
QVERIFY(controller->externalChanges.pending);
controller->newCanvas();
QTRY_VERIFY(!session.document());
QVERIFY(!controller->externalChanges.recheck->isActive());
QVERIFY(!controller->externalChanges.pending);
}
void ExternalChangeTests::oneCheckRunsAtATime()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
// Three asks before the watcher speaks: one reload between them.
for (int ask = 0; ask < 3; ++ask) {
controller->externalChanges.pending = true;
controller->resumeExternalChangeCheck();
}
QVERIFY(controller->externalChanges.checking);
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000);
settle();
QCOMPARE(controller->externalChanges.reloadCount, 1);
QVERIFY(!controller->externalChanges.checking);
}
void ExternalChangeTests::aCheckWithoutAProjectOrDuringOurSaveEnds()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
// During our own save a change is ours: dropped.
controller->externalChanges.saving = true;
controller->externalChanges.pending = true;
controller->resumeExternalChangeCheck();
QVERIFY(!controller->externalChanges.checking);
QVERIFY(!controller->externalChanges.pending);
controller->externalChanges.saving = false;
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000);
// Without a document nothing is checked, and checking ends.
EditorSession empty;
ProjectController idle(empty);
idle.externalChanges.pending = true;
idle.resumeExternalChangeCheck();
QVERIFY(!idle.externalChanges.checking);
QVERIFY(!idle.externalChanges.pending);
}
QTEST_MAIN(ExternalChangeTests)
#include "ExternalChangeTests.moc"