OmaPhoto 1.0.0: the Linux port of Compositor
A layered image editor for Linux and Omarchy, ported from Compositor (github.com/robbietilton/Compositor), whose Swift source is its specification. It reads and writes Compositor's .comp projects. Packages for Ubuntu, Fedora and Arch come from the release workflow; scripts/install/ holds one install script per distribution family, and flake.nix serves NixOS. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
c864304695
564 files changed
+100804
No files matched your search
@@ -0,0 +1,417 @@
|
||||
#include "Document/LayerMask.h"
|
||||
#include "ProjectFixtures.h"
|
||||
#include <QImageWriter>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
#include <limits>
|
||||
|
||||
class ProjectTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void projectRoundTripSurvivesPackageMove();
|
||||
void masksAndLargeImagesRoundTrip();
|
||||
void overwriteReplacesPackageAndDropsRemovedAssets();
|
||||
void failedSavePreservesPreviouslySavedPackage();
|
||||
void unsupportedCorruptAndUnsafeMetadataAreRejected();
|
||||
void missingAndForeignImagesAreRejected();
|
||||
void filesMustBePlainFilesInsideTheProject();
|
||||
void sizeLimitsHoldOnSaveAndLoad();
|
||||
void numbersThatAreNoNumbersAreRefusedOnSave();
|
||||
void savesAndLoadsAreLogged();
|
||||
};
|
||||
|
||||
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);
|
||||
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(8), 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\": 7", "\"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\": 7", "\"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\": 7", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
|
||||
// Declared: Qt rounds a token just past Int64's minimum.
|
||||
try {
|
||||
overwrite(metadata, QByteArray(good).replace("\"version\": 7", "\"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\": 7", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
|
||||
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"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 100 million pixels, 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";
|
||||
heavy.masks.insert({id, asset(gray(10'000, 10'000, 255), "whole budget")});
|
||||
heavy.masks.insert({second, asset(gray(1, 1, 255), "one too many")});
|
||||
QCOMPARE(projectError([&] { ProjectStore::save(heavy, root.filePath("Heavy.comp")); }), std::optional(ProjectError::Kind::tooLarge));
|
||||
// 99,990,000 and 10,000 pixels fill the budget exactly.
|
||||
heavy.masks.insert_or_assign(id, asset(gray(10'000, 9'999, 255), "nearly all"));
|
||||
heavy.masks.insert_or_assign(second, asset(gray(100, 100, 255), "the rest"));
|
||||
QCOMPARE(projectError([&] { ProjectStore::save(heavy, root.filePath("Heavy.comp")); }), std::nullopt);
|
||||
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Heavy.comp")); }), std::nullopt);
|
||||
// On load the same sum comes from the files' headers.
|
||||
const QString firstMask = root.filePath("Heavy.comp/images/") + uuidString(id) + ".mask.png";
|
||||
overwrite(firstMask, png(gray(10'000, 10'000, 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"
|
||||
Reference in new issue
Block a user