Reload a project changed on disk, fingerprinted by its manifest and image sizes (12.13a)
This commit is contained in:
1 parent
aa253d9715
commit
838d5b1459
20 files changed
+1388
-8
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user