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,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"
|
||||
Reference in new issue
Block a user