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

+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include "DialogDesk.h"
#include "Document/BrushStroke.h"
#include "Document/ProjectWorkspace.h"
#include "IO/ProjectController.h"
#include "IO/ProjectDigest.h"
#include "IO/ProjectStore.h"
#include <QFutureWatcher>
#include <QSaveFile>
#include <QTemporaryDir>
#include <QtTest>
// Shared by the external change tests: packages written elsewhere.
// A saved two-layer project: an image below a blank layer.
inline QString savedProject(const QTemporaryDir &root)
{
EditorSession session;
session.createDocument(8, 6);
QImage image = BrushRaster::context(8, 6, false);
image.fill(QColor(200, 120, 60));
session.insert(ImportedImage(image, image, QStringLiteral("Image")));
session.renameLayer(session.activeLayerID().value(), QStringLiteral("Base"));
session.addBlankLayer();
const QString path = root.filePath(QStringLiteral("Watched.comp"));
ProjectStore::save(session.projectSnapshot().value(), path);
return path;
}
// Rewrites the package as another app would: a new name.
inline void renameFirstLayerOnDisk(const QString &path, const QString &name)
{
ProjectSnapshot snapshot = ProjectStore::load(path);
snapshot.manifest.layers[0].name = name;
ProjectStore::save(snapshot, path);
}
// Rewrites a file through a temporary, as a script does.
inline void rewrite(const QString &file, const QByteArray &bytes)
{
QSaveFile out(file);
if (!out.open(QIODevice::WriteOnly) || out.write(bytes) != bytes.size() || !out.commit())
throw std::runtime_error("could not rewrite " + file.toStdString());
}
inline QByteArray contents(const QString &file)
{
QFile in(file);
if (!in.open(QIODevice::ReadOnly))
throw std::runtime_error("could not read " + file.toStdString());
return in.readAll();
}
inline bool answered(const std::function<void(std::function<void(bool)>)> &call)
{
std::optional<bool> result;
call([&](bool value) { result = value; });
return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) && result.value();
}
// Opens the project as the app does: watched, digest kept.
inline std::unique_ptr<ProjectController> opened(EditorSession &session, const QString &path)
{
auto controller = std::make_unique<ProjectController>(session);
if (!answered([&](auto done) { controller->open(path, done); }))
throw std::runtime_error("the project did not open");
return controller;
}
inline QString firstName(const EditorSession &session)
{
return session.document().value().layers.front().name;
}
// Longer than the watcher's quiet time and a digest.
inline void settle()
{
QTest::qWait(900);
}
+262
View File
@@ -0,0 +1,262 @@
#include "ExternalChangeFixtures.h"
// What holds a change back, and what a reload keeps.
class ExternalChangeGateTests : public QObject {
Q_OBJECT
private slots:
void aFailedSaveKeepsItsGuardUntilTheAlertIsAnswered();
void aManagingWorkspaceHoldsTheCheck();
void theBackOffDoublesToACap();
void aLoadLandingElsewhereInstallsNothing();
void aReloadKeepsTheWholeViewAndAnnouncesItLast();
void anUnreadableManifestAsksNothing();
void saveAsAndOpenMoveTheWatch();
void aSecondChangeWhileAskingIsHeard();
};
void ExternalChangeGateTests::aFailedSaveKeepsItsGuardUntilTheAlertIsAnswered()
{
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("Unsaved here"));
// Save As into a folder that takes nothing.
const QString locked = root.filePath(QStringLiteral("Locked"));
QVERIFY(QDir().mkpath(locked));
QVERIFY(QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::ExeOwner));
DialogDesk desk;
desk.replies = {locked + QStringLiteral("/Copy.comp"), "OK"};
// While the alert is up, changes on disk drop.
desk.note = [&] {
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
QTest::qWait(900);
return QStringLiteral("saving=%1 reloads=%2").arg(controller->externalChanges.saving).arg(controller->externalChanges.reloadCount);
};
QVERIFY(!answered([&](auto done) { controller->save(true, done); }));
QCOMPARE(desk.seen.size(), 2);
QVERIFY2(desk.seen[1].endsWith("|saving=1 reloads=0"), qPrintable(desk.seen[1]));
QVERIFY(!controller->externalChanges.saving);
settle();
QCOMPARE(desk.seen.size(), 2);
QCOMPARE(controller->externalChanges.reloadCount, 0);
QCOMPARE(firstName(session), QString("Unsaved here"));
QFile::setPermissions(locked, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
}
void ExternalChangeGateTests::aManagingWorkspaceHoldsTheCheck()
{
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();
workspace.newCanvas();
const std::shared_ptr<ProjectTab> other = workspace.tabs().back();
other->session.createDocument(8, 6);
other->session.addBlankLayer();
workspace.select(watched->id);
// Closing the other tab asks; meanwhile the workspace manages.
DialogDesk desk;
desk.replies = {"Cancel"};
desk.note = [&] {
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
QTest::qWait(900);
return QStringLiteral("managing=%1 reloads=%2 recheck=%3")
.arg(workspace.isManaging())
.arg(watched->controller.externalChanges.reloadCount)
.arg(watched->controller.externalChanges.recheck->isActive());
};
std::optional<bool> closed;
workspace.close(other->id, [&] { closed = true; });
QTRY_VERIFY(closed.has_value());
QCOMPARE(desk.seen.size(), 1);
QVERIFY2(desk.seen[0].endsWith("|managing=1 reloads=0 recheck=1"), qPrintable(desk.seen[0]));
QTRY_COMPARE_WITH_TIMEOUT(watched->controller.externalChanges.reloadCount, 1, 4000);
QCOMPARE(firstName(watched->session), QString("Renamed elsewhere"));
}
void ExternalChangeGateTests::theBackOffDoublesToACap()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
ExternalChangeState &state = controller->externalChanges;
QCOMPARE(state.recheck->timerType(), Qt::PreciseTimer);
session.setIsProjectBusy(true);
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
QTRY_VERIFY_WITH_TIMEOUT(state.recheck->isActive(), 4000);
QCOMPARE(state.recheck->interval(), 250);
// Each busy check waits twice as long, to 32 seconds.
for (const int expected : {500, 1000, 2000, 4000, 8000, 16000, 32000, 32000}) {
state.pending = true;
controller->resumeExternalChangeCheck();
QTRY_COMPARE(state.recheck->interval(), expected);
QVERIFY(state.recheck->isActive());
}
QCOMPARE(state.recheckAttempt, 7);
session.setIsProjectBusy(false);
state.pending = true;
controller->resumeExternalChangeCheck();
QTRY_COMPARE_WITH_TIMEOUT(state.reloadCount, 1, 4000);
QCOMPARE(state.recheckAttempt, 0);
}
void ExternalChangeGateTests::aLoadLandingElsewhereInstallsNothing()
{
for (const bool clears : {false, true}) {
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
// As the reload begins, the project moves or closes.
bool moved = false;
QObject::connect(&session, &EditorSession::changed, [&] {
if (moved || !session.isProjectBusy())
return;
moved = true;
if (clears)
session.clearProject();
else
session.setProjectPath(root.filePath(QStringLiteral("Elsewhere.comp")));
});
renameFirstLayerOnDisk(path, QStringLiteral("Renamed elsewhere"));
QTRY_VERIFY_WITH_TIMEOUT(moved, 4000);
QTRY_VERIFY(!session.isProjectBusy());
settle();
QCOMPARE(controller->externalChanges.reloadCount, 0);
QCOMPARE(session.document().has_value(), !clears);
if (!clears)
QCOMPARE(firstName(session), QString("Base"));
}
}
void ExternalChangeGateTests::aReloadKeepsTheWholeViewAndAnnouncesItLast()
{
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);
session.viewport.translate(QSizeF(40, -30));
const CanvasViewport view = session.viewport;
const QUuid base = session.document().value().layers[0].id;
const QUuid blank = session.document().value().layers[1].id;
session.selectLayer(blank);
session.extendSelection(base);
// Disk drops the other selected layer; the active one stays.
ProjectSnapshot fewer = ProjectStore::load(path);
fewer.manifest.layers.erase(fewer.manifest.layers.begin() + 1);
std::optional<std::tuple<CanvasViewport, std::optional<QUuid>, QSet<QUuid>>> last;
QObject::connect(&session, &EditorSession::changed, [&] { last = {session.viewport, session.activeLayerID(), session.selectedLayerIDs()}; });
session.reloadProject(fewer);
QVERIFY(session.viewport == view);
QCOMPARE(session.activeLayerID().value(), base);
QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{base});
QVERIFY(std::get<0>(last.value()) == view);
QCOMPARE(std::get<1>(last.value()), std::optional<QUuid>(base));
QCOMPARE(std::get<2>(last.value()), QSet<QUuid>{base});
// Both layers on disk: the last signal holds both selected.
session.reloadProject(ProjectStore::load(path));
session.selectLayer(blank);
session.extendSelection(base);
session.reloadProject(ProjectStore::load(path));
QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{base, blank}));
QCOMPARE(std::get<2>(last.value()), (QSet<QUuid>{base, blank}));
}
void ExternalChangeGateTests::anUnreadableManifestAsksNothing()
{
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("Unsaved here"));
DialogDesk desk;
// No digest: the package is left alone, no question.
const QString manifest = path + QStringLiteral("/manifest.json");
QVERIFY(QFile::remove(manifest));
QVERIFY(QDir().mkpath(manifest));
settle();
QVERIFY(desk.seen.isEmpty());
QCOMPARE(controller->externalChanges.reloadCount, 0);
QCOMPARE(firstName(session), QString("Unsaved here"));
}
void ExternalChangeGateTests::saveAsAndOpenMoveTheWatch()
{
QTemporaryDir root;
const QString path = savedProject(root);
EditorSession session;
const auto controller = opened(session, path);
const QString copy = root.filePath(QStringLiteral("Copy.comp"));
{
DialogDesk desk;
desk.replies = {copy};
QVERIFY(answered([&](auto done) { controller->save(true, done); }));
}
QCOMPARE(controller->externalChanges.watcher->path, copy);
QCOMPARE(controller->externalChanges.knownDigest.value(), ProjectDigest::compute(copy));
renameFirstLayerOnDisk(copy, QStringLiteral("Copy renamed"));
QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000);
QCOMPARE(firstName(session), QString("Copy renamed"));
// The former package is no longer this project.
renameFirstLayerOnDisk(path, QStringLiteral("Former renamed"));
settle();
QCOMPARE(controller->externalChanges.reloadCount, 1);
// Opening another package moves the watch there.
QVERIFY(answered([&](auto done) { controller->open(path, done); }));
QCOMPARE(controller->externalChanges.watcher->path, path);
QCOMPARE(firstName(session), QString("Former renamed"));
renameFirstLayerOnDisk(copy, QStringLiteral("Copy again"));
settle();
QCOMPARE(controller->externalChanges.reloadCount, 1);
}
void ExternalChangeGateTests::aSecondChangeWhileAskingIsHeard()
{
for (const bool revert : {false, true}) {
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("Unsaved here"));
DialogDesk desk;
desk.replies = revert ? QStringList{"Revert"} : QStringList{"Keep Mine", "Keep Mine"};
// A second version arrives while the first question waits.
desk.note = [&] {
if (desk.seen.isEmpty()) {
renameFirstLayerOnDisk(path, QStringLiteral("Second version"));
QTest::qWait(900);
}
return QStringLiteral("pending=%1").arg(controller->externalChanges.pending);
};
renameFirstLayerOnDisk(path, QStringLiteral("First version"));
QTRY_COMPARE_WITH_TIMEOUT(desk.seen.size(), revert ? 1 : 2, 6000);
QVERIFY2(desk.seen[0].endsWith("|pending=1"), qPrintable(desk.seen[0]));
QTRY_VERIFY_WITH_TIMEOUT(!controller->externalChanges.checking, 4000);
settle();
QCOMPARE(desk.seen.size(), revert ? 1 : 2);
QVERIFY(!controller->externalChanges.pending);
QCOMPARE(controller->externalChanges.knownDigest.value(), ProjectDigest::compute(path));
QCOMPARE(controller->externalChanges.reloadCount, revert ? 1 : 0);
QCOMPARE(firstName(session), revert ? QString("Second version") : QString("Unsaved here"));
}
}
QTEST_MAIN(ExternalChangeGateTests)
#include "ExternalChangeGateTests.moc"
+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"
+72
View File
@@ -0,0 +1,72 @@
#include "IO/ProjectDigest.h"
#include <QDir>
#include <QFile>
#include <QTemporaryDir>
#include <QtTest>
#include <dirent.h>
#include <dlfcn.h>
#include <unistd.h>
// An image gone between listing and looking: no digest.
namespace {
// The file to remove once its folder's listing ends.
QByteArray vanishing;
bool vanished = false;
bool listsImages(DIR *directory)
{
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)).endsWith("/images");
}
// At the listing's end the image goes, before any lookup.
template <typename Entry> Entry *listed(DIR *directory, Entry *entry)
{
if (!entry && !vanishing.isEmpty() && !vanished && listsImages(directory))
vanished = ::unlink(vanishing.constData()) == 0;
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 listed(directory, real(directory));
}
extern "C" struct dirent64 *readdir64(DIR *directory)
{
static const auto real = reinterpret_cast<struct dirent64 *(*)(DIR *)>(dlsym(RTLD_NEXT, "readdir64"));
return listed(directory, real(directory));
}
class ProjectDigestRaceTests : public QObject {
Q_OBJECT
private slots:
void anImageGoneMidListingGivesNoDigest();
};
void ProjectDigestRaceTests::anImageGoneMidListingGivesNoDigest()
{
QTemporaryDir root;
const QString path = root.filePath(QStringLiteral("Race.comp"));
QVERIFY(QDir().mkpath(path + QStringLiteral("/images")));
for (const QString &name : {QStringLiteral("manifest.json"), QStringLiteral("images/A.png"), QStringLiteral("images/B.png")}) {
QFile file(path + QLatin1Char('/') + name);
QVERIFY(file.open(QIODevice::WriteOnly) && file.write("bytes") == 5);
}
// Listed whole, one image looked up after it is gone.
const QByteArray whole = ProjectDigest::compute(path);
vanishing = QFile::encodeName(path + QStringLiteral("/images/A.png"));
QVERIFY_THROWS_EXCEPTION(std::runtime_error, ProjectDigest::compute(path));
QVERIFY(vanished);
// Settled, the package fingerprints again, without it.
vanishing.clear();
QVERIFY(ProjectDigest::compute(path) != whole);
}
QTEST_GUILESS_MAIN(ProjectDigestRaceTests)
#include "ProjectDigestRaceTests.moc"
+218
View File
@@ -0,0 +1,218 @@
#include "IO/ProjectDigest.h"
#include "IO/ProjectWatcher.h"
#include <QDir>
#include <QFile>
#include <QCryptographicHash>
#include <QElapsedTimer>
#include <QTemporaryDir>
#include <QtEndian>
#include <fcntl.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <QtTest>
// Swift's ProjectWatcher: one report a burst, the watch kept.
namespace {
void writeFile(const QString &file, const QByteArray &bytes)
{
QFile out(file);
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate) || out.write(bytes) != bytes.size())
throw std::runtime_error("could not write " + file.toStdString());
}
// A package: a manifest and an images folder.
struct Package {
QTemporaryDir root;
QString path = root.filePath(QStringLiteral("Watched.comp"));
int reports = 0;
std::unique_ptr<ProjectWatcher> watcher;
Package()
{
QDir().mkpath(path + QStringLiteral("/images"));
writeFile(path + QStringLiteral("/manifest.json"), "{}");
watcher = std::make_unique<ProjectWatcher>(path, [this] { reports += 1; });
}
QTimer &timer(const char *name) const { return *watcher->findChild<QTimer *>(QString::fromLatin1(name)); }
};
}
class ProjectWatcherTests : public QObject {
Q_OBJECT
private slots:
void aBurstOfWritesReportsOnce();
void aManifestWrittenInPlaceIsSeen();
void anImageAddedIsSeen();
void reArmingStopsOnceEveryPathIsWatched();
void aMissingPathIsTriedForTwoSeconds();
void aDigestIgnoresTheOrderImagesWereWritten();
void anExchangeKeepingTheOldPackageIsSeen();
void deliveryWaitsTheWholeQuietTime();
void aDigestHashesTheManifestNamesAndSizes();
void anUnreadableManifestGivesNoDigest();
};
void ProjectWatcherTests::aBurstOfWritesReportsOnce()
{
Package package;
for (int count = 0; count < 3; ++count) {
writeFile(package.path + QStringLiteral("/manifest.json"), QByteArray::number(count));
QTest::qWait(50);
}
QCOMPARE(package.reports, 0);
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000);
// Single shot: quiet afterwards.
QTest::qWait(1000);
QCOMPARE(package.reports, 1);
QVERIFY(!package.timer("delivery").isActive());
}
void ProjectWatcherTests::aManifestWrittenInPlaceIsSeen()
{
Package package;
// Its entries are unchanged: only the manifest's own watch speaks.
writeFile(package.path + QStringLiteral("/manifest.json"), "{ \"changed\": true }");
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000);
}
void ProjectWatcherTests::anImageAddedIsSeen()
{
Package package;
// Inside images/: only that folder's watch speaks.
writeFile(package.path + QStringLiteral("/images/A.png"), "png");
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000);
writeFile(package.path + QStringLiteral("/images/B.png"), "png");
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 2, 2000);
}
void ProjectWatcherTests::reArmingStopsOnceEveryPathIsWatched()
{
Package package;
writeFile(package.path + QStringLiteral("/manifest.json"), "[]");
QTRY_VERIFY_WITH_TIMEOUT(package.timer("rearm").isActive(), 1000);
QTRY_VERIFY_WITH_TIMEOUT(!package.timer("rearm").isActive(), 500);
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000);
// A swapped-in manifest is watched again by path.
QFile::remove(package.path + QStringLiteral("/manifest.json"));
writeFile(package.path + QStringLiteral("/manifest.json"), "{ \"swapped\": true }");
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 2, 2000);
writeFile(package.path + QStringLiteral("/manifest.json"), "{ \"again\": true }");
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 3, 2000);
}
void ProjectWatcherTests::aMissingPathIsTriedForTwoSeconds()
{
Package package;
int tries = 0;
connect(&package.timer("rearm"), &QTimer::timeout, [&] { tries += 1; });
QVERIFY(QDir(package.path + QStringLiteral("/images")).removeRecursively());
QTRY_VERIFY_WITH_TIMEOUT(package.timer("rearm").isActive(), 1000);
// A hundred milliseconds a try, twenty tries.
QTest::qWait(1000);
QVERIFY(package.timer("rearm").isActive());
QTRY_VERIFY_WITH_TIMEOUT(!package.timer("rearm").isActive(), 2000);
QCOMPARE(tries, 20);
}
void ProjectWatcherTests::aDigestIgnoresTheOrderImagesWereWritten()
{
QTemporaryDir root;
const auto make = [&](const QString &name, const QStringList &order) {
const QString path = root.filePath(name);
QDir().mkpath(path + QStringLiteral("/images"));
writeFile(path + QStringLiteral("/manifest.json"), "{}");
for (const QString &image : order)
writeFile(path + QStringLiteral("/images/") + image, image.toUtf8());
return ProjectDigest::compute(path);
};
const QStringList names{QStringLiteral("A.png"), QStringLiteral("BB.png"), QStringLiteral("CCC.png"), QStringLiteral("D.mask.png")};
QStringList reversed = names;
std::reverse(reversed.begin(), reversed.end());
QCOMPARE(make(QStringLiteral("One.comp"), names), make(QStringLiteral("Two.comp"), reversed));
}
void ProjectWatcherTests::anExchangeKeepingTheOldPackageIsSeen()
{
Package package;
const QString other = package.root.filePath(QStringLiteral("Other.comp"));
QDir().mkpath(other + QStringLiteral("/images"));
writeFile(other + QStringLiteral("/manifest.json"), "{ \"other\": true }");
QTest::qWait(500);
QCOMPARE(package.reports, 0);
// Another writer swaps packages and keeps the old one aside.
QCOMPARE(syscall(SYS_renameat2, AT_FDCWD, QFile::encodeName(other).constData(), AT_FDCWD, QFile::encodeName(package.path).constData(), 2), 0L);
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000);
// The new package's own files are watched now.
writeFile(package.path + QStringLiteral("/manifest.json"), "{ \"edited\": true }");
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 2, 2000);
writeFile(package.path + QStringLiteral("/images/A.png"), "png");
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 3, 2000);
// A sibling that is not the package says nothing.
writeFile(package.root.filePath(QStringLiteral("Unrelated.txt")), "text");
QTest::qWait(600);
QCOMPARE(package.reports, 3);
}
void ProjectWatcherTests::deliveryWaitsTheWholeQuietTime()
{
Package package;
QCOMPARE(package.timer("delivery").timerType(), Qt::PreciseTimer);
QCOMPARE(package.timer("rearm").timerType(), Qt::PreciseTimer);
QCOMPARE(package.timer("delivery").interval(), 300);
QCOMPARE(package.timer("rearm").interval(), 100);
// Quiet time counts from the last write, not the first.
writeFile(package.path + QStringLiteral("/manifest.json"), "[]");
QTest::qWait(200);
QCOMPARE(package.reports, 0);
QElapsedTimer clock;
clock.start();
writeFile(package.path + QStringLiteral("/manifest.json"), "[1]");
QTRY_COMPARE_WITH_TIMEOUT(package.reports, 1, 2000);
QVERIFY2(clock.elapsed() >= 300, qPrintable(QString::number(clock.elapsed())));
QTest::qWait(500);
QCOMPARE(package.reports, 1);
}
void ProjectWatcherTests::aDigestHashesTheManifestNamesAndSizes()
{
QTemporaryDir root;
const QString path = root.filePath(QStringLiteral("Digest.comp"));
const QString images = path + QStringLiteral("/images/");
QDir().mkpath(images + QStringLiteral("folder"));
writeFile(path + QStringLiteral("/manifest.json"), "M");
writeFile(images + QStringLiteral("b.png"), "bbb");
writeFile(images + QStringLiteral("a.png"), "aa");
writeFile(images + QStringLiteral(".hidden"), "h");
QVERIFY(QFile::link(images + QStringLiteral("a.png"), images + QStringLiteral("link.png")));
QVERIFY(QFile::link(images + QStringLiteral("gone.png"), images + QStringLiteral("dangling.png")));
// The oracle: manifest, then sorted names and sizes.
QCryptographicHash oracle(QCryptographicHash::Sha256);
oracle.addData(QByteArrayView("M"));
for (const auto &[name, size] : {std::pair(".hidden", 1), std::pair("a.png", 2), std::pair("b.png", 3)}) {
oracle.addData(QByteArrayView(name));
const quint64 little = qToLittleEndian(quint64(size));
oracle.addData(QByteArrayView(reinterpret_cast<const char *>(&little), sizeof little));
}
QCOMPARE(ProjectDigest::compute(path), oracle.result());
// Same size, other bytes: not read, so unchanged.
writeFile(images + QStringLiteral("a.png"), "zz");
QCOMPARE(ProjectDigest::compute(path), oracle.result());
// Same size, another name: changed.
QVERIFY(QFile::rename(images + QStringLiteral("b.png"), images + QStringLiteral("c.png")));
QVERIFY(ProjectDigest::compute(path) != oracle.result());
}
void ProjectWatcherTests::anUnreadableManifestGivesNoDigest()
{
QTemporaryDir root;
const QString path = root.filePath(QStringLiteral("Broken.comp"));
// A folder opens but cannot be read.
QDir().mkpath(path + QStringLiteral("/manifest.json"));
QVERIFY_THROWS_EXCEPTION(std::runtime_error, ProjectDigest::compute(path));
// A size that is not what reads: /proc says zero.
QVERIFY(QDir(path + QStringLiteral("/manifest.json")).removeRecursively());
QVERIFY(QFile::link(QStringLiteral("/proc/self/status"), path + QStringLiteral("/manifest.json")));
QVERIFY_THROWS_EXCEPTION(std::runtime_error, ProjectDigest::compute(path));
}
QTEST_GUILESS_MAIN(ProjectWatcherTests)
#include "ProjectWatcherTests.moc"
+4
View File
@@ -152,6 +152,10 @@ compositor_test(ScrubbableTransformTests)
compositor_test(NewCanvasSheetTests)
compositor_test(OmarchyThemeTests)
compositor_test(ProjectControllerTests)
compositor_test(ExternalChangeTests)
compositor_test(ExternalChangeGateTests)
compositor_test(ProjectWatcherTests)
compositor_test(ProjectDigestRaceTests)
compositor_test(ProjectCopyTests)
compositor_test(ProjectOpenTests)
compositor_test(ProjectReceiveTests)