467 lines
26 KiB
C++
467 lines
26 KiB
C++
#include "Document/DocumentLimits.h"
|
||
#include "Document/LayerMask.h"
|
||
#include "BudgetFixtures.h"
|
||
#include "ProjectFixtures.h"
|
||
#include <QBuffer>
|
||
#include <QImageWriter>
|
||
#include <QTemporaryDir>
|
||
#include <QtTest>
|
||
#include <limits>
|
||
|
||
namespace {
|
||
// A PNG whose header claims pixels it never holds.
|
||
QByteArray claimingPNG(int width, int height)
|
||
{
|
||
QByteArray data;
|
||
QBuffer buffer(&data);
|
||
QImageWriter(&buffer, "png").write(QImage(1, 1, QImage::Format_Grayscale8));
|
||
for (int index = 0; index < 4; ++index) {
|
||
data[16 + index] = char(quint32(width) >> (24 - 8 * index));
|
||
data[20 + index] = char(quint32(height) >> (24 - 8 * index));
|
||
}
|
||
// The header chunk's CRC-32 over its type and fields.
|
||
quint32 crc = 0xFFFFFFFF;
|
||
for (int index = 12; index < 29; ++index) {
|
||
crc ^= uchar(data[index]);
|
||
for (int bit = 0; bit < 8; ++bit)
|
||
crc = (crc >> 1) ^ (0xEDB88320 & (0u - (crc & 1)));
|
||
}
|
||
crc = ~crc;
|
||
for (int index = 0; index < 4; ++index)
|
||
data[29 + index] = char(crc >> (24 - 8 * index));
|
||
return data;
|
||
}
|
||
}
|
||
|
||
class ProjectTests : public QObject {
|
||
Q_OBJECT
|
||
private slots:
|
||
void theCurrentFormatVersionIsOneTheReaderAccepts();
|
||
void projectRoundTripSurvivesPackageMove();
|
||
void masksAndLargeImagesRoundTrip();
|
||
void overwriteReplacesPackageAndDropsRemovedAssets();
|
||
void failedSavePreservesPreviouslySavedPackage();
|
||
void unsupportedCorruptAndUnsafeMetadataAreRejected();
|
||
void missingAndForeignImagesAreRejected();
|
||
void filesMustBePlainFilesInsideTheProject();
|
||
void sizeLimitsHoldOnSaveAndLoad();
|
||
void numbersThatAreNoNumbersAreRefusedOnSave();
|
||
void savesAndLoadsAreLogged();
|
||
};
|
||
|
||
// Saves write current; loads take supported: they must agree.
|
||
void ProjectTests::theCurrentFormatVersionIsOneTheReaderAccepts()
|
||
{
|
||
QVERIFY(ProjectManifest::supports(ProjectManifest::current));
|
||
QVERIFY((ProjectManifest::supported == std::pair<qint64, qint64>(1, 11)));
|
||
QCOMPARE(ProjectManifest{}.version, ProjectManifest::current);
|
||
QVERIFY(!ProjectManifest::supports(0) && !ProjectManifest::supports(ProjectManifest::current + 1));
|
||
QCOMPARE(QString(ProjectError::unsupportedVersion(12).what()), QString("This project uses format version 12. This app supports versions 1–11."));
|
||
}
|
||
|
||
void ProjectTests::projectRoundTripSurvivesPackageMove()
|
||
{
|
||
QTemporaryDir root;
|
||
const ProjectSnapshot before = twoLayers();
|
||
const QString original = root.filePath("Original.comp"), moved = root.filePath("Moved.comp");
|
||
ProjectStore::save(before, original);
|
||
QVERIFY(QDir().rename(original, moved));
|
||
const ProjectSnapshot loaded = ProjectStore::load(moved);
|
||
QCOMPARE(loaded.manifest.encoded(), before.manifest.encoded());
|
||
QCOMPARE(loaded.manifest.documentID, before.manifest.documentID);
|
||
QCOMPARE(loaded.manifest.resolution, std::optional<double>(300));
|
||
QCOMPARE(loaded.manifest.activeLayerID, before.manifest.activeLayerID);
|
||
QCOMPARE(loaded.manifest.layers[0].name, QStringLiteral("Paint & sky \U0001F324"));
|
||
QCOMPARE(loaded.manifest.layers[0].isVisible, false);
|
||
QCOMPARE(loaded.manifest.layers[0].transform, before.manifest.layers[0].transform);
|
||
// The blank layer has no pixels; the image layer does.
|
||
QCOMPARE(int(loaded.images.size()), 1);
|
||
QVERIFY(loaded.masks.empty());
|
||
const ImportedImage &pixels = loaded.images.at(before.manifest.layers[0].id);
|
||
QCOMPARE(pixels.image(), halfRed());
|
||
QCOMPARE(pixels.image().format(), QImage::Format_RGBA8888_Premultiplied);
|
||
QCOMPARE(pixels.thumbnail, halfRed());
|
||
// Up to 96 pixels the thumbnail is the image itself.
|
||
QCOMPARE(pixels.thumbnail.cacheKey(), pixels.image().cacheKey());
|
||
QCOMPARE(pixels.name, QStringLiteral("Paint & sky \U0001F324"));
|
||
// The package holds the manifest and the images, nothing else.
|
||
QCOMPARE(QDir(moved).entryList(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden), (QStringList{"images", "manifest.json"}));
|
||
QCOMPARE(QDir(moved + "/images").entryList(QDir::Files), QStringList{uuidString(before.manifest.layers[0].id) + ".png"});
|
||
}
|
||
|
||
void ProjectTests::masksAndLargeImagesRoundTrip()
|
||
{
|
||
QTemporaryDir root;
|
||
ProjectSnapshot masked = twoLayers();
|
||
const QUuid id = masked.manifest.layers[0].id;
|
||
QImage soft = gray(40, 20, 0);
|
||
for (int y = 0; y < 20; ++y) {
|
||
for (int x = 0; x < 40; ++x)
|
||
soft.scanLine(y)[x] = uchar(x * 6 + y);
|
||
}
|
||
masked.manifest.layers[0].maskFile = uuidString(id) + ".mask.png";
|
||
masked.manifest.layers[0].maskEnabled = false;
|
||
masked.manifest.layers[0].maskLinked = false;
|
||
masked.manifest.layers[0].maskPlacement = LayerTransform{.origin = {3, 4}, .size = {50, 60}, .rotation = 10};
|
||
masked.masks.insert({id, asset(soft, "mask")});
|
||
// A long, thin image: its thumbnail keeps one pixel across.
|
||
const QUuid wideID = QUuid::createUuid();
|
||
masked.manifest.layers.push_back({.id = wideID, .name = "Wide", .isVisible = true, .transform = {.origin = {0, 0}, .size = {300, 2}},
|
||
.imageFile = uuidString(wideID) + ".png"});
|
||
masked.images.insert({wideID, asset(noise(300, 2, 5), "wide")});
|
||
// At 96 pixels the thumbnail is still the image.
|
||
const QUuid edgeID = QUuid::createUuid();
|
||
masked.manifest.layers.push_back({.id = edgeID, .name = "Edge", .isVisible = true, .transform = {.origin = {0, 0}, .size = {96, 10}},
|
||
.imageFile = uuidString(edgeID) + ".png"});
|
||
masked.images.insert({edgeID, asset(noise(96, 10, 6), "edge")});
|
||
ProjectStore::save(masked, root.filePath("Masked.comp"));
|
||
const ProjectSnapshot loaded = ProjectStore::load(root.filePath("Masked.comp"));
|
||
QCOMPARE(loaded.manifest.encoded(), masked.manifest.encoded());
|
||
QCOMPARE(loaded.masks.at(id).image(), soft);
|
||
QCOMPARE(loaded.masks.at(id).image().format(), QImage::Format_Grayscale8);
|
||
const LayerMask mask = loaded.mask(loaded.manifest.layers[0]).value();
|
||
QCOMPARE(mask.isEnabled, false);
|
||
QCOMPARE(mask.isLinked, false);
|
||
QCOMPARE(mask.placement.value(), (LayerTransform{.origin = {3, 4}, .size = {50, 60}, .rotation = 10}));
|
||
QCOMPARE(loaded.images.at(wideID).image(), noise(300, 2, 5));
|
||
QCOMPARE(loaded.images.at(wideID).thumbnail.size(), QSize(96, 1));
|
||
QCOMPARE(loaded.images.at(wideID).thumbnail.format(), QImage::Format_RGBA8888_Premultiplied);
|
||
QCOMPARE(loaded.images.at(edgeID).thumbnail.cacheKey(), loaded.images.at(edgeID).image().cacheKey());
|
||
QVERIFY(loaded.images.at(wideID).thumbnail.cacheKey() != loaded.images.at(wideID).image().cacheKey());
|
||
}
|
||
|
||
void ProjectTests::overwriteReplacesPackageAndDropsRemovedAssets()
|
||
{
|
||
QTemporaryDir root;
|
||
const QString destination = root.filePath("Overwrite.comp");
|
||
ProjectSnapshot snapshot = twoLayers();
|
||
ProjectStore::save(snapshot, destination);
|
||
QCOMPARE(QDir(destination + "/images").entryList(QDir::Files).size(), 1);
|
||
snapshot.manifest.layers.erase(snapshot.manifest.layers.begin());
|
||
snapshot.images.clear();
|
||
ProjectStore::save(snapshot, destination);
|
||
const ProjectSnapshot loaded = ProjectStore::load(destination);
|
||
QVERIFY(loaded.images.empty());
|
||
QCOMPARE(int(loaded.manifest.layers.size()), 1);
|
||
QVERIFY(QDir(destination + "/images").entryList(QDir::Files).isEmpty());
|
||
// Nothing is left beside the project: no staging, no copy.
|
||
QCOMPARE(QDir(root.path()).entryList(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden), QStringList{"Overwrite.comp"});
|
||
// A plain file there is replaced too; trailing slashes pass.
|
||
const QString file = root.filePath("WasAFile.comp");
|
||
overwrite(file, "not a project");
|
||
ProjectStore::save(snapshot, file + "/");
|
||
QCOMPARE(int(ProjectStore::load(file).manifest.layers.size()), 1);
|
||
QCOMPARE(QDir(root.path()).entryList(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden), (QStringList{"Overwrite.comp", "WasAFile.comp"}));
|
||
}
|
||
|
||
void ProjectTests::failedSavePreservesPreviouslySavedPackage()
|
||
{
|
||
QTemporaryDir root;
|
||
const ProjectSnapshot snapshot = twoLayers();
|
||
const QString path = root.filePath("Safe.comp");
|
||
ProjectStore::save(snapshot, path);
|
||
const QByteArray original = contents(path + "/manifest.json");
|
||
const QStringList beside{"Safe.comp"};
|
||
ProjectSnapshot future = snapshot;
|
||
future.manifest.version = 99;
|
||
try {
|
||
ProjectStore::save(future, path);
|
||
QFAIL("an unsupported version was saved");
|
||
} catch (const ProjectError &error) {
|
||
QCOMPARE(error.kind, ProjectError::Kind::version);
|
||
QCOMPARE(error.version, std::optional(qint64(99)));
|
||
}
|
||
// Pixels named but missing; a mask that is no mask.
|
||
ProjectSnapshot hollow = snapshot;
|
||
hollow.images.clear();
|
||
QCOMPARE(projectError([&] { ProjectStore::save(hollow, path); }), std::optional(ProjectError::Kind::missingImage));
|
||
ProjectSnapshot coloured = snapshot;
|
||
coloured.manifest.layers[1].maskFile = uuidString(coloured.manifest.layers[1].id) + ".mask.png";
|
||
QCOMPARE(projectError([&] { ProjectStore::save(coloured, path); }), std::optional(ProjectError::Kind::missingImage));
|
||
coloured.masks.insert({coloured.manifest.layers[1].id, asset(halfRed(), "not gray")});
|
||
QCOMPARE(projectError([&] { ProjectStore::save(coloured, path); }), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(contents(path + "/manifest.json"), original);
|
||
QCOMPARE(QDir(root.path()).entryList(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden), beside);
|
||
// No folder to write in: a file, a missing one.
|
||
overwrite(root.filePath("not-a-directory"), "x");
|
||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not create .*"));
|
||
QVERIFY_EXCEPTION_THROWN(ProjectStore::save(snapshot, root.filePath("not-a-directory/CannotSave.comp")), std::runtime_error);
|
||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not create .*"));
|
||
QVERIFY_EXCEPTION_THROWN(ProjectStore::save(snapshot, root.filePath("missing/CannotSave.comp")), std::runtime_error);
|
||
QVERIFY(!QFileInfo::exists(root.filePath("missing")));
|
||
QCOMPARE(contents(path + "/manifest.json"), original);
|
||
QCOMPARE(int(ProjectStore::load(path).manifest.layers.size()), 2);
|
||
}
|
||
|
||
void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
|
||
{
|
||
QTemporaryDir root;
|
||
const ProjectSnapshot snapshot = twoLayers();
|
||
const QString path = root.filePath("Invalid.comp"), metadata = path + "/manifest.json";
|
||
ProjectStore::save(snapshot, path);
|
||
const QByteArray good = contents(metadata);
|
||
// Each edit below replaces this text; it must be there.
|
||
QVERIFY(good.contains("\"version\": 11"));
|
||
const auto loading = [&](const QByteArray &manifest) {
|
||
overwrite(metadata, manifest);
|
||
return projectError([&] { ProjectStore::load(path); });
|
||
};
|
||
// A version out of range is named whatever else fails.
|
||
const QByteArray future = R"({"format": "com.compositor.project", "version": 42, "layers": "from the future"})";
|
||
for (const qint64 strange : {qint64(42), qint64(12), qint64(0)}) {
|
||
try {
|
||
overwrite(metadata, QByteArray(future).replace("42", QByteArray::number(strange)));
|
||
ProjectStore::load(path);
|
||
QFAIL("a strange version opened");
|
||
} catch (const ProjectError &error) {
|
||
QCOMPARE(error.kind, ProjectError::Kind::version);
|
||
QCOMPARE(error.version, std::optional(strange));
|
||
}
|
||
}
|
||
// Versions in range go on to decode the rest.
|
||
QCOMPARE(loading(QByteArray(future).replace("42", "7")), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(loading(QByteArray(future).replace("42", "1")), std::optional(ProjectError::Kind::invalid));
|
||
// Another format is invalid whatever version it claims.
|
||
QCOMPARE(loading(QByteArray(future).replace("compositor", "example")), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 0")), std::optional(ProjectError::Kind::version));
|
||
// Swift's Int holds 64 bits, each one: no double between.
|
||
const qint64 most = std::numeric_limits<qint64>::max(), least = std::numeric_limits<qint64>::min();
|
||
for (const qint64 wild : {qint64(2147483648), qint64(-2147483648), qint64(-1), qint64(9007199254740993), most, least}) {
|
||
try {
|
||
overwrite(metadata, QByteArray(good).replace("\"version\": 11", "\"version\": " + QByteArray::number(wild)));
|
||
ProjectStore::load(path);
|
||
QFAIL("a wild version opened");
|
||
} catch (const ProjectError &error) {
|
||
QCOMPARE(error.kind, ProjectError::Kind::version);
|
||
QCOMPARE(error.version, std::optional(wild));
|
||
QVERIFY(QString(error.what()).contains(QString::number(wild)));
|
||
}
|
||
}
|
||
QCOMPARE(loading(QByteArray(good).replace("\"width\": 100", "\"width\": 3000000000")), std::optional(ProjectError::Kind::tooLarge));
|
||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
|
||
// Declared: Qt rounds a token just past Int64's minimum.
|
||
try {
|
||
overwrite(metadata, QByteArray(good).replace("\"version\": 11", "\"version\": -9223372036854775809"));
|
||
ProjectStore::load(path);
|
||
QFAIL("a version below every integer opened");
|
||
} catch (const ProjectError &error) {
|
||
QCOMPARE(error.version, std::optional(least));
|
||
}
|
||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 11", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(loading(QByteArray(good).replace("com.compositor.project", "com.example.project")), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(loading("not json"), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(loading("[1, 2]"), std::optional(ProjectError::Kind::invalid));
|
||
// A file name that climbs out of the project.
|
||
const QByteArray name = (uuidString(snapshot.manifest.layers[0].id) + ".png").toUtf8();
|
||
QCOMPARE(loading(QByteArray(good).replace(name, "../../outside.png")), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(loading(good), std::nullopt);
|
||
// Not a folder, no manifest inside, no such place.
|
||
QCOMPARE(projectError([&] { ProjectStore::load(metadata); }), std::optional(ProjectError::Kind::invalid));
|
||
QVERIFY(QFile::remove(metadata));
|
||
QCOMPARE(projectError([&] { ProjectStore::load(path); }), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Nowhere.comp")); }), std::optional(ProjectError::Kind::invalid));
|
||
}
|
||
|
||
void ProjectTests::missingAndForeignImagesAreRejected()
|
||
{
|
||
QTemporaryDir root;
|
||
ProjectSnapshot snapshot = twoLayers();
|
||
const QUuid id = snapshot.manifest.layers[0].id;
|
||
snapshot.manifest.layers[0].maskFile = uuidString(id) + ".mask.png";
|
||
snapshot.masks.insert({id, asset(gray(8, 8, 200), "mask")});
|
||
const QString path = root.filePath("Images.comp");
|
||
ProjectStore::save(snapshot, path);
|
||
const QString image = path + "/images/" + uuidString(id) + ".png", mask = path + "/images/" + uuidString(id) + ".mask.png";
|
||
const QByteArray goodImage = contents(image), goodMask = contents(mask);
|
||
const auto written = [](const QImage &pixels, const char *format) {
|
||
QByteArray data;
|
||
QBuffer buffer(&data);
|
||
QImageWriter writer(&buffer, format);
|
||
writer.write(pixels);
|
||
return data;
|
||
};
|
||
const auto loading = [&] { return projectError([&] { ProjectStore::load(path); }); };
|
||
QVERIFY(QFile::remove(image));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage));
|
||
// A JPEG named PNG; sixteen-bit channels; bytes of no image.
|
||
overwrite(image, written(halfRed().convertToFormat(QImage::Format_RGB888), "jpeg"));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage));
|
||
overwrite(image, written(halfRed().convertToFormat(QImage::Format_RGBA64), "png"));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage));
|
||
overwrite(image, "\x89PNG broken");
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage));
|
||
// A PNG cut short: the header reads, the pixels fail.
|
||
overwrite(image, goodImage.left(goodImage.size() * 6 / 10));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage));
|
||
// Any eight-bit PNG serves as pixels; masks must be gray.
|
||
overwrite(image, written(halfRed().convertToFormat(QImage::Format_RGB888), "png"));
|
||
QCOMPARE(loading(), std::nullopt);
|
||
QCOMPARE(ProjectStore::load(path).images.at(id).image().format(), QImage::Format_RGBA8888_Premultiplied);
|
||
overwrite(image, goodImage);
|
||
overwrite(mask, written(halfRed(), "png"));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::invalid));
|
||
QVERIFY(QFile::remove(mask));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::missingImage));
|
||
overwrite(mask, goodMask);
|
||
QCOMPARE(loading(), std::nullopt);
|
||
}
|
||
|
||
void ProjectTests::filesMustBePlainFilesInsideTheProject()
|
||
{
|
||
QTemporaryDir root;
|
||
const ProjectSnapshot snapshot = twoLayers();
|
||
const QString path = root.filePath("Links.comp");
|
||
ProjectStore::save(snapshot, path);
|
||
const QString image = path + "/images/" + uuidString(snapshot.manifest.layers[0].id) + ".png", metadata = path + "/manifest.json";
|
||
const auto loading = [&] { return projectError([&] { ProjectStore::load(path); }); };
|
||
// Links leading out, within, and nowhere; then a folder.
|
||
QVERIFY(QFile::rename(image, root.filePath("outside.png")));
|
||
QVERIFY(QFile::link(root.filePath("outside.png"), image));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::invalid));
|
||
QVERIFY(QFile::remove(image));
|
||
QVERIFY(QFile::copy(root.filePath("outside.png"), path + "/images/real.png"));
|
||
QVERIFY(QFile::link(path + "/images/real.png", image));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge));
|
||
QVERIFY(QFile::remove(image));
|
||
QVERIFY(QFile::link(root.filePath("nothing.png"), image));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::invalid));
|
||
QVERIFY(QFile::remove(image));
|
||
QVERIFY(QDir().mkdir(image));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge));
|
||
QVERIFY(QDir().rmdir(image));
|
||
QVERIFY(QFile::copy(root.filePath("outside.png"), image));
|
||
QCOMPARE(loading(), std::nullopt);
|
||
// The manifest is held to the same rules.
|
||
QVERIFY(QFile::rename(metadata, root.filePath("manifest.json")));
|
||
QVERIFY(QFile::link(root.filePath("manifest.json"), metadata));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::invalid));
|
||
// A manifest that may not be read.
|
||
QVERIFY(QFile::remove(metadata));
|
||
QVERIFY(QFile::rename(root.filePath("manifest.json"), metadata));
|
||
QVERIFY(QFile::setPermissions(metadata, QFileDevice::Permissions()));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::invalid));
|
||
QVERIFY(QFile::setPermissions(metadata, QFileDevice::ReadOwner | QFileDevice::WriteOwner));
|
||
QVERIFY(QFile::rename(metadata, root.filePath("manifest.json")));
|
||
QVERIFY(QFile::link(root.filePath("manifest.json"), metadata));
|
||
// A project reached through a link is still itself.
|
||
QVERIFY(QFile::remove(metadata));
|
||
QVERIFY(QFile::rename(root.filePath("manifest.json"), metadata));
|
||
QVERIFY(QFile::link(path, root.filePath("Shortcut.comp")));
|
||
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Shortcut.comp")); }), std::nullopt);
|
||
}
|
||
|
||
void ProjectTests::sizeLimitsHoldOnSaveAndLoad()
|
||
{
|
||
QTemporaryDir root;
|
||
const ProjectSnapshot snapshot = twoLayers();
|
||
const QString path = root.filePath("Limits.comp");
|
||
ProjectStore::save(snapshot, path);
|
||
const QUuid id = snapshot.manifest.layers[0].id;
|
||
const QString image = path + "/images/" + uuidString(id) + ".png", metadata = path + "/manifest.json";
|
||
const QByteArray goodImage = contents(image), goodManifest = contents(metadata);
|
||
const auto loading = [&] { return projectError([&] { ProjectStore::load(path); }); };
|
||
// A manifest over 4 MiB, on disk and in memory.
|
||
overwrite(metadata, goodManifest + QByteArray(4 * 1024 * 1024, ' '));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge));
|
||
overwrite(metadata, goodManifest + QByteArray(4 * 1024 * 1024 - goodManifest.size(), ' '));
|
||
QCOMPARE(loading(), std::nullopt);
|
||
overwrite(metadata, goodManifest);
|
||
ProjectSnapshot wordy = snapshot;
|
||
for (int index = 0; index < 270; ++index)
|
||
wordy.manifest.layers.push_back({.id = QUuid::createUuid(), .name = QString(16'000, 'n'), .isVisible = true,
|
||
.transform = {.origin = {0, 0}, .size = {1, 1}}, .imageFile = std::nullopt});
|
||
QVERIFY(wordy.manifest.encoded().size() > 4 * 1024 * 1024);
|
||
QCOMPARE(projectError([&] { ProjectStore::save(wordy, root.filePath("Wordy.comp")); }), std::optional(ProjectError::Kind::tooLarge));
|
||
// An encoded image over 512 MiB, as a sparse file.
|
||
{
|
||
QFile sparse(image);
|
||
QVERIFY(sparse.open(QIODevice::ReadWrite));
|
||
QVERIFY(sparse.resize(512ll * 1024 * 1024 + 1));
|
||
}
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge));
|
||
// At 512 MiB the padded PNG still opens.
|
||
{
|
||
QFile sparse(image);
|
||
QVERIFY(sparse.open(QIODevice::ReadWrite));
|
||
QVERIFY(sparse.resize(512ll * 1024 * 1024));
|
||
}
|
||
QCOMPARE(loading(), std::nullopt);
|
||
overwrite(image, goodImage);
|
||
// A side over 30,000 pixels, read from the header alone.
|
||
const auto png = [](const QImage &pixels) {
|
||
QByteArray data;
|
||
QBuffer buffer(&data);
|
||
QImageWriter(&buffer, "png").write(pixels);
|
||
return data;
|
||
};
|
||
overwrite(image, png(gray(30'001, 1, 9)));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge));
|
||
overwrite(image, png(gray(1, 30'001, 9)));
|
||
QCOMPARE(loading(), std::optional(ProjectError::Kind::tooLarge));
|
||
overwrite(image, png(gray(30'000, 1, 9)));
|
||
QCOMPARE(loading(), std::nullopt);
|
||
overwrite(image, png(gray(1, 30'000, 9)));
|
||
QCOMPARE(loading(), std::nullopt);
|
||
overwrite(image, goodImage);
|
||
ProjectSnapshot long1 = snapshot;
|
||
long1.images.insert_or_assign(id, asset(gray(30'001, 1, 9).convertToFormat(QImage::Format_RGBA8888_Premultiplied), "long"));
|
||
QCOMPARE(projectError([&] { ProjectStore::save(long1, root.filePath("Long.comp")); }), std::optional(ProjectError::Kind::tooLarge));
|
||
// Masks share the document budget, apart from the images'.
|
||
ProjectSnapshot heavy = snapshot;
|
||
const QUuid second = heavy.manifest.layers[1].id;
|
||
heavy.manifest.layers[0].maskFile = uuidString(id) + ".mask.png";
|
||
heavy.manifest.layers[1].maskFile = uuidString(second) + ".mask.png";
|
||
// Rows of 30,000 and a remainder fill the budget exactly.
|
||
const qint64 budget = DocumentLimits::documentPixelBudget();
|
||
const qint64 rows = (budget - 1) / 30'000, rest = budget - rows * 30'000;
|
||
// Saving judges sizes before pixels: one over, summed, refused.
|
||
heavy.masks.insert({id, asset(gray(int(rest) + 1, 1, 255), "one too many")});
|
||
heavy.masks.insert({second, claimed(30'000, int(rows))});
|
||
QCOMPARE(projectError([&] { ProjectStore::save(heavy, root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::tooLarge));
|
||
QVERIFY(!heavy.masks.at(second).raster->hasMaterializedPixels());
|
||
heavy.masks.insert_or_assign(id, asset(gray(int(rest), 1, 255), "the rest"));
|
||
heavy.masks.insert_or_assign(second, asset(gray(1, 1, 255), "small"));
|
||
QCOMPARE(projectError([&] { ProjectStore::save(heavy, root.filePath("Heavy.comp")); }), std::nullopt);
|
||
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::nullopt);
|
||
// Loaded, headers sum exactly to it; images count apart.
|
||
const QString firstMask = root.filePath("Heavy.comp/images/") + uuidString(id) + ".mask.png";
|
||
const QString secondMask = root.filePath("Heavy.comp/images/") + uuidString(second) + ".mask.png";
|
||
overwrite(secondMask, claimingPNG(30'000, int(rows)));
|
||
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::missingImage));
|
||
// The first mask's one more pixel tips the sum over.
|
||
overwrite(firstMask, png(gray(int(rest) + 1, 1, 255)));
|
||
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::tooLarge));
|
||
}
|
||
|
||
void ProjectTests::numbersThatAreNoNumbersAreRefusedOnSave()
|
||
{
|
||
QTemporaryDir root;
|
||
const auto saving = [&](const ProjectSnapshot &snapshot) { return projectError([&] { ProjectStore::save(snapshot, root.filePath("Numbers.comp")); }); };
|
||
for (const double value : {std::nan(""), std::numeric_limits<double>::infinity(), -std::numeric_limits<double>::infinity()}) {
|
||
ProjectSnapshot resolution = twoLayers(), opacity = twoLayers(), origin = twoLayers();
|
||
resolution.manifest.resolution = value;
|
||
opacity.manifest.layers[1].opacity = value;
|
||
origin.manifest.layers[1].transform.origin.setX(value);
|
||
QCOMPARE(saving(resolution), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(saving(opacity), std::optional(ProjectError::Kind::invalid));
|
||
QCOMPARE(saving(origin), std::optional(ProjectError::Kind::invalid));
|
||
}
|
||
QVERIFY(!QFileInfo::exists(root.filePath("Numbers.comp")));
|
||
}
|
||
|
||
void ProjectTests::savesAndLoadsAreLogged()
|
||
{
|
||
QTemporaryDir root;
|
||
const QString path = root.filePath("Logged.comp");
|
||
QTest::ignoreMessage(QtInfoMsg, QRegularExpression("saved \".*Logged.comp\" with 2 layers and 1 images"));
|
||
ProjectStore::save(twoLayers(), path);
|
||
QTest::ignoreMessage(QtInfoMsg, QRegularExpression("loaded \".*Logged.comp\" with 2 layers and 1 images"));
|
||
ProjectStore::load(path);
|
||
}
|
||
|
||
QTEST_MAIN(ProjectTests)
|
||
#include "ProjectTests.moc"
|