Files
OmaPhoto/tests/ProjectManifestTests.cpp
T

490 lines
25 KiB
C++

#include "ProjectFixtures.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryDir>
#include <QtTest>
#include <limits>
namespace {
// A manifest as macOS writes it: spaced colons, escaped slashes.
const char *fromMacOS = R"({
"activeLayerID" : "0F0E0D0C-0B0A-4908-8706-050403020100",
"colorSpace" : "sRGB",
"documentID" : "00112233-4455-4677-8899-AABBCCDDEEFF",
"format" : "com.compositor.project",
"height" : 80,
"layers" : [
{
"blendMode" : "Color Dodge",
"id" : "0F0E0D0C-0B0A-4908-8706-050403020100",
"imageFile" : "0F0E0D0C-0B0A-4908-8706-050403020100.png",
"isVisible" : false,
"maskEnabled" : false,
"maskFile" : "0F0E0D0C-0B0A-4908-8706-050403020100.mask.png",
"maskLinked" : false,
"maskPlacement" : {
"flipX" : false,
"flipY" : true,
"origin" : [ 3, 4.5 ],
"rotation" : 10,
"sampling" : "Smooth",
"size" : [ 50, 60 ]
},
"name" : "Paint \/ sky",
"opacity" : 0.25,
"text" : {
"alignment" : "Center",
"blue" : 0.75,
"boxSize" : [ 300, 150 ],
"content" : "Two \/ lines\nof text",
"fontName" : "Helvetica-Bold",
"fontSize" : 36.5,
"green" : 0.5,
"leading" : 0,
"red" : 0.25,
"tracking" : -2
},
"transform" : {
"flipX" : true,
"flipY" : false,
"origin" : [ -27.5, 88.25 ],
"rotation" : 38,
"sampling" : "Nearest",
"size" : [ 123, 47 ]
}
}
],
"resolution" : 300,
"version" : 7,
"width" : 100
})";
QJsonObject object(const QByteArray &json)
{
return QJsonDocument::fromJson(json).object();
}
QByteArray json(const QJsonObject &object)
{
return QJsonDocument(object).toJson();
}
// Sets `key` on the first layer; undefined removes it.
QByteArray withLayer(const QByteArray &manifest, const QString &key, const QJsonValue &value)
{
QJsonObject whole = object(manifest);
QJsonArray layers = whole.value("layers").toArray();
QJsonObject layer = layers.at(0).toObject();
value.isUndefined() ? layer.remove(key) : void(layer.insert(key, value));
layers.replace(0, layer);
whole.insert("layers", layers);
return json(whole);
}
QByteArray with(const QByteArray &manifest, const QString &key, const QJsonValue &value)
{
QJsonObject whole = object(manifest);
value.isUndefined() ? whole.remove(key) : void(whole.insert(key, value));
return json(whole);
}
bool decodes(const QByteArray &manifest)
{
return !projectError([&] { ProjectManifest::decoded(manifest); }).has_value();
}
}
class ProjectManifestTests : public QObject {
Q_OBJECT
private slots:
void aMacOSManifestDecodesFieldByField();
void encodingWritesSwiftsKeysAndLeavesAbsentOnesOut();
void everyRawValueRoundTrips();
void decodingRefusesWhatSwiftRefuses();
void validationFollowsTheFormatsVersions_data();
void validationFollowsTheFormatsVersions();
};
void ProjectManifestTests::aMacOSManifestDecodesFieldByField()
{
const ProjectManifest manifest = ProjectManifest::decoded(fromMacOS);
QCOMPARE(manifest.format, QString("com.compositor.project"));
QCOMPARE(manifest.version, 7);
QCOMPARE(manifest.colorSpace, QString("sRGB"));
QCOMPARE(manifest.resolution, std::optional<double>(300));
QCOMPARE(manifest.documentID, QUuid("{00112233-4455-4677-8899-aabbccddeeff}"));
QCOMPARE(manifest.width, 100);
QCOMPARE(manifest.height, 80);
QCOMPARE(manifest.activeLayerID, std::optional(QUuid("{0f0e0d0c-0b0a-4908-8706-050403020100}")));
QCOMPARE(int(manifest.layers.size()), 1);
const ProjectLayerRecord &layer = manifest.layers[0];
QCOMPARE(layer.id, QUuid("{0f0e0d0c-0b0a-4908-8706-050403020100}"));
QCOMPARE(layer.name, QString("Paint / sky"));
QCOMPARE(layer.isVisible, false);
QCOMPARE(layer.transform, (LayerTransform{.origin = {-27.5, 88.25}, .size = {123, 47}, .rotation = 38, .flipX = true, .flipY = false,
.sampling = LayerSampling::nearest}));
QCOMPARE(layer.imageFile, std::optional(QString("0F0E0D0C-0B0A-4908-8706-050403020100.png")));
QCOMPARE(layer.opacity, std::optional(0.25));
QCOMPARE(layer.blendMode, std::optional(LayerBlendMode::colorDodge));
QCOMPARE(layer.maskFile, std::optional(QString("0F0E0D0C-0B0A-4908-8706-050403020100.mask.png")));
QCOMPARE(layer.maskEnabled, std::optional(false));
QCOMPARE(layer.maskLinked, std::optional(false));
QCOMPARE(layer.maskPlacement.value(), (LayerTransform{.origin = {3, 4.5}, .size = {50, 60}, .rotation = 10, .flipX = false, .flipY = true,
.sampling = LayerSampling::smooth}));
QVERIFY(!layer.parentID && !layer.isGroup && !layer.maskSourceID);
const LayerTextStyle &text = layer.text.value();
QCOMPARE(text.content, QString("Two / lines\nof text"));
QVERIFY(text.fontName == QString("Helvetica-Bold") && text.fontSize == 36.5 && text.alignment == TextAlignment::center);
QVERIFY(text.red == 0.25 && text.green == 0.5 && text.blue == 0.75 && text.tracking == -2 && text.leading == 0);
QCOMPARE(text.boxSize, std::optional(QSizeF(300, 150)));
// What it writes back, the decoder reads the same.
QCOMPARE(ProjectManifest::decoded(manifest.encoded()).encoded(), manifest.encoded());
}
void ProjectManifestTests::encodingWritesSwiftsKeysAndLeavesAbsentOnesOut()
{
ProjectSnapshot snapshot = twoLayers();
snapshot.manifest.resolution = std::nullopt;
snapshot.manifest.activeLayerID = std::nullopt;
// Two spaces a level, as Swift prints it.
QVERIFY(snapshot.manifest.encoded().startsWith("{\n \"colorSpace\": \"sRGB\",\n \"documentID\": "));
QVERIFY(snapshot.manifest.encoded().contains("\n \"id\": "));
QVERIFY(snapshot.manifest.encoded().endsWith("\n}\n"));
const QJsonObject bare = object(snapshot.manifest.encoded());
QCOMPARE(bare.keys(), (QStringList{"colorSpace", "documentID", "format", "height", "layers", "version", "width"}));
QCOMPARE(bare.value("documentID").toString(), uuidString(snapshot.manifest.documentID));
QCOMPARE(bare.value("documentID").toString(), bare.value("documentID").toString().toUpper());
const QJsonObject blank = bare.value("layers").toArray().at(1).toObject();
QCOMPARE(blank.keys(), (QStringList{"id", "isVisible", "name", "transform"}));
const QJsonObject transform = bare.value("layers").toArray().at(0).toObject().value("transform").toObject();
QCOMPARE(transform.keys(), (QStringList{"flipX", "flipY", "origin", "rotation", "sampling", "size"}));
QCOMPARE(transform.value("origin"), QJsonValue(QJsonArray{-27.5, 88.25}));
QCOMPARE(transform.value("size"), QJsonValue(QJsonArray{123, 47}));
QCOMPARE(transform.value("sampling").toString(), QString("Nearest"));
// Every optional, once present, under its own key.
const QUuid parent = QUuid::createUuid(), source = QUuid::createUuid();
ProjectLayerRecord full = snapshot.manifest.layers[0];
full.parentID = parent;
full.isGroup = false;
full.opacity = 0.5;
full.blendMode = LayerBlendMode::colorBurn;
full.maskFile = "m.png";
full.maskEnabled = true;
full.maskSourceID = source;
full.maskPlacement = LayerTransform{.origin = {1, 2}, .size = {3, 4}};
full.maskLinked = false;
snapshot.manifest.layers = {full};
snapshot.manifest.resolution = 144.5;
snapshot.manifest.activeLayerID = full.id;
const QJsonObject whole = object(snapshot.manifest.encoded());
QCOMPARE(whole.value("resolution").toDouble(), 144.5);
QCOMPARE(whole.value("activeLayerID").toString(), uuidString(full.id));
const QJsonObject layer = whole.value("layers").toArray().at(0).toObject();
QCOMPARE(layer.keys(), (QStringList{"blendMode", "id", "imageFile", "isGroup", "isVisible", "maskEnabled", "maskFile", "maskLinked",
"maskPlacement", "maskSourceID", "name", "opacity", "parentID", "transform"}));
QCOMPARE(layer.value("parentID").toString(), uuidString(parent));
QCOMPARE(layer.value("maskSourceID").toString(), uuidString(source));
QCOMPARE(layer.value("blendMode").toString(), QString("Color Burn"));
QCOMPARE(layer.value("isGroup"), QJsonValue(false));
QCOMPARE(layer.value("maskEnabled"), QJsonValue(true));
QCOMPARE(layer.value("maskLinked"), QJsonValue(false));
QCOMPARE(layer.value("opacity").toDouble(), 0.5);
QCOMPARE(layer.value("maskPlacement").toObject().value("size"), QJsonValue(QJsonArray{3, 4}));
const ProjectLayerRecord back = ProjectManifest::decoded(snapshot.manifest.encoded()).layers[0];
QCOMPARE(back.parentID, std::optional(parent));
QCOMPARE(back.maskSourceID, std::optional(source));
QCOMPARE(back.isGroup, std::optional(false));
QCOMPARE(back.maskEnabled, std::optional(true));
QCOMPARE(back.maskLinked, std::optional(false));
QCOMPARE(back.maskPlacement.value(), (LayerTransform{.origin = {1, 2}, .size = {3, 4}}));
}
void ProjectManifestTests::everyRawValueRoundTrips()
{
const QStringList blends{"Normal", "Darken", "Multiply", "Color Burn", "Linear Burn", "Lighten", "Screen", "Color Dodge",
"Linear Dodge (Add)", "Overlay", "Soft Light", "Hard Light", "Vivid Light", "Linear Light", "Pin Light", "Hard Mix",
"Difference", "Exclusion", "Subtract", "Divide", "Hue", "Saturation", "Color", "Luminosity"};
for (int index = 0; index < blends.size(); ++index) {
QCOMPARE(rawValue(LayerBlendMode(index)), blends[index]);
QCOMPARE(layerBlendMode(blends[index]), std::optional(LayerBlendMode(index)));
}
QCOMPARE(layerBlendMode("normal"), std::nullopt);
QCOMPARE(layerBlendMode(""), std::nullopt);
const QStringList samplings{"Nearest", "Smooth", "High quality"};
for (int index = 0; index < samplings.size(); ++index) {
QCOMPARE(rawValue(LayerSampling(index)), samplings[index]);
QCOMPARE(layerSampling(samplings[index]), std::optional(LayerSampling(index)));
}
QCOMPARE(layerSampling("High Quality"), std::nullopt);
QCOMPARE(uuidString(QUuid("{0f0e0d0c-0b0a-4908-8706-0504030201ab}")), QString("0F0E0D0C-0B0A-4908-8706-0504030201AB"));
}
void ProjectManifestTests::decodingRefusesWhatSwiftRefuses()
{
const QByteArray good = fromMacOS;
QVERIFY(decodes(good));
// Required keys, each missing in turn.
for (const char *key : {"format", "version", "colorSpace", "documentID", "width", "height", "layers"})
QVERIFY2(!decodes(with(good, key, QJsonValue::Undefined)), key);
for (const char *key : {"id", "name", "isVisible", "transform"})
QVERIFY2(!decodes(withLayer(good, key, QJsonValue::Undefined)), key);
for (const char *key : {"origin", "size", "rotation", "flipX", "flipY", "sampling"}) {
QJsonObject transform = object(good).value("layers").toArray().at(0).toObject().value("transform").toObject();
transform.remove(key);
QVERIFY2(!decodes(withLayer(good, "transform", transform)), key);
}
// Optional keys may be missing or null.
for (const char *key : {"resolution", "activeLayerID"}) {
QVERIFY2(decodes(with(good, key, QJsonValue::Undefined)), key);
QVERIFY2(decodes(with(good, key, QJsonValue::Null)), key);
}
for (const char *key : {"imageFile", "parentID", "isGroup", "opacity", "blendMode", "maskFile", "maskEnabled", "maskSourceID",
"maskPlacement", "maskLinked"}) {
QVERIFY2(decodes(withLayer(good, key, QJsonValue::Undefined)), key);
QVERIFY2(decodes(withLayer(good, key, QJsonValue::Null)), key);
}
QVERIFY(!ProjectManifest::decoded(withLayer(good, "opacity", QJsonValue::Null)).layers[0].opacity.has_value());
// The wrong type under each key.
QVERIFY(!decodes(with(good, "format", 7)));
QVERIFY(!decodes(with(good, "colorSpace", true)));
QVERIFY(!decodes(with(good, "version", "7")));
QVERIFY(!decodes(with(good, "width", 100.5)));
QVERIFY(decodes(with(good, "width", 100.0)));
// Swift's Int holds 64 bits: wild sizes decode, validation refuses.
QCOMPARE(ProjectManifest::decoded(with(good, "height", 1e12)).height, qint64(1'000'000'000'000));
QCOMPARE(ProjectManifest::decoded(with(good, "width", -3e9)).width, qint64(-3'000'000'000));
// Every bit of a whole token survives: no double between.
for (const qint64 exact : {qint64(9007199254740993), std::numeric_limits<qint64>::max(), std::numeric_limits<qint64>::min()})
QCOMPARE(ProjectManifest::decoded(with(good, "height", QJsonValue(exact))).height, exact);
QVERIFY(!decodes(QByteArray(good).replace("\"width\" : 100", "\"width\" : 9223372036854775808")));
QVERIFY(!decodes(with(good, "height", 1e19)));
QVERIFY(!decodes(with(good, "height", -1e19)));
QVERIFY(!decodes(with(good, "version", 7.5)));
QVERIFY(!decodes(with(good, "resolution", "300")));
QVERIFY(!decodes(with(good, "layers", QJsonObject())));
QVERIFY(!decodes(with(good, "layers", QJsonArray{7})));
QVERIFY(!decodes(withLayer(good, "name", 7)));
QVERIFY(!decodes(withLayer(good, "isVisible", 1)));
QVERIFY(!decodes(withLayer(good, "isGroup", "yes")));
QVERIFY(!decodes(withLayer(good, "opacity", "0.5")));
QVERIFY(!decodes(withLayer(good, "imageFile", 7)));
QVERIFY(!decodes(withLayer(good, "blendMode", "Glow")));
QVERIFY(!decodes(withLayer(good, "blendMode", 3)));
QVERIFY(!decodes(withLayer(good, "transform", "upright")));
QVERIFY(!decodes(withLayer(good, "maskPlacement", QJsonArray{1, 2})));
// UUIDs: Swift's form only, in either case.
QVERIFY(decodes(with(good, "documentID", "00112233-4455-4677-8899-aabbccddeeff")));
QVERIFY(!decodes(with(good, "documentID", "{00112233-4455-4677-8899-AABBCCDDEEFF}")));
QVERIFY(!decodes(with(good, "documentID", "001122334455467788 99AABBCCDDEEFF")));
QVERIFY(!decodes(with(good, "documentID", "00112233-4455-4677-8899-AABBCCDDEEFG")));
QVERIFY(!decodes(with(good, "documentID", 7)));
QVERIFY(!decodes(with(good, "documentID", "00112233-4455-4677-8899-AABBCCDDEEFF\n")));
QVERIFY(!decodes(with(good, "documentID", "\n00112233-4455-4677-8899-AABBCCDDEEFF")));
QVERIFY(!decodes(withLayer(good, "parentID", "not-a-uuid")));
// Points and sizes are two-number arrays; a third is ignored.
QJsonObject transform = object(good).value("layers").toArray().at(0).toObject().value("transform").toObject();
transform.insert("origin", QJsonArray{1, 2, 3});
QCOMPARE(ProjectManifest::decoded(withLayer(good, "transform", transform)).layers[0].transform.origin, QPointF(1, 2));
transform.insert("origin", QJsonArray{1});
QVERIFY(!decodes(withLayer(good, "transform", transform)));
transform.insert("origin", QJsonArray{"1", 2});
QVERIFY(!decodes(withLayer(good, "transform", transform)));
transform.insert("origin", QJsonObject{{"x", 1}, {"y", 2}});
QVERIFY(!decodes(withLayer(good, "transform", transform)));
transform.insert("origin", QJsonArray{1, 2});
transform.insert("sampling", "Bicubic");
QVERIFY(!decodes(withLayer(good, "transform", transform)));
transform.insert("sampling", "High quality");
transform.insert("flipX", 0);
QVERIFY(!decodes(withLayer(good, "transform", transform)));
transform.insert("flipX", false);
transform.insert("rotation", "38");
QVERIFY(!decodes(withLayer(good, "transform", transform)));
}
void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
{
QTest::addColumn<QString>("change");
QTest::addColumn<int>("version");
QTest::addColumn<std::optional<ProjectError::Kind>>("refused");
const auto invalid = std::optional(ProjectError::Kind::invalid), tooLarge = std::optional(ProjectError::Kind::tooLarge);
const std::optional<ProjectError::Kind> fine;
for (int version = 1; version <= 9; ++version)
QTest::addRow("plain at version %d", version) << "" << version << fine;
QTest::newRow("version 0") << "" << 0 << std::optional(ProjectError::Kind::version);
QTest::newRow("version 12") << "" << 12 << std::optional(ProjectError::Kind::version);
QTest::newRow("another format") << "format" << 7 << invalid;
QTest::newRow("another colour space") << "colorSpace" << 7 << invalid;
QTest::newRow("resolution 0.5") << "resolution=0.5" << 7 << invalid;
QTest::newRow("resolution 9601") << "resolution=9601" << 7 << invalid;
QTest::newRow("resolution 1") << "resolution=1" << 1 << fine;
QTest::newRow("resolution 9600") << "resolution=9600" << 1 << fine;
QTest::newRow("width 0") << "width=0" << 7 << tooLarge;
QTest::newRow("width 30001") << "width=30001" << 7 << tooLarge;
QTest::newRow("width 30000") << "width=30000" << 7 << fine;
QTest::newRow("height 0") << "height=0" << 7 << tooLarge;
QTest::newRow("height 30001") << "height=30001" << 7 << tooLarge;
QTest::newRow("height 30000") << "height=30000" << 7 << fine;
QTest::newRow("10001 layers") << "layers=10001" << 7 << tooLarge;
QTest::newRow("10000 layers") << "layers=10000" << 7 << fine;
QTest::newRow("opacity before version 3") << "opacity=0.5" << 2 << invalid;
QTest::newRow("opacity at version 3") << "opacity=0.5" << 3 << fine;
QTest::newRow("full opacity before version 3") << "opacity=1" << 2 << fine;
QTest::newRow("opacity above one") << "opacity=1.5" << 7 << invalid;
QTest::newRow("opacity below zero") << "opacity=-0.5" << 7 << invalid;
QTest::newRow("a blend mode before version 3") << "blend" << 2 << invalid;
QTest::newRow("a blend mode at version 3") << "blend" << 3 << fine;
QTest::newRow("a mask before version 4") << "mask" << 3 << invalid;
QTest::newRow("a mask at version 4") << "mask" << 4 << fine;
QTest::newRow("a mask under another name") << "maskName" << 7 << invalid;
QTest::newRow("mask enabled without a mask") << "maskEnabledAlone" << 7 << invalid;
QTest::newRow("a placement without a mask") << "placementAlone" << 7 << invalid;
QTest::newRow("a placement that is no transform") << "placementInvalid" << 7 << invalid;
QTest::newRow("a placed mask") << "placement" << 7 << fine;
QTest::newRow("a clipping link before version 5") << "link" << 4 << invalid;
QTest::newRow("a clipping link at version 5") << "link" << 5 << fine;
QTest::newRow("a clipping link to nothing") << "linkNowhere" << 7 << invalid;
QTest::newRow("a folder at version 1") << "folder" << 1 << invalid;
QTest::newRow("a folder at version 2") << "folder" << 2 << fine;
QTest::newRow("an empty folder at version 1") << "folderEmpty" << 1 << invalid;
QTest::newRow("an empty folder at version 2") << "folderEmpty" << 2 << fine;
QTest::newRow("a folder that blends") << "folderBlend" << 7 << invalid;
QTest::newRow("a folder that fades before version 8") << "folderOpacity" << 7 << invalid;
QTest::newRow("a folder that fades at version 8") << "folderOpacity" << 8 << fine;
QTest::newRow("a folder that blends at version 8") << "folderBlend" << 8 << invalid;
QTest::newRow("a folder with pixels") << "folderPixels" << 7 << invalid;
QTest::newRow("a folder mask before version 6") << "folderMask" << 5 << invalid;
QTest::newRow("a folder mask at version 6") << "folderMask" << 6 << fine;
QTest::newRow("a parent that is no folder") << "parentLayer" << 7 << invalid;
QTest::newRow("the same id twice") << "duplicate" << 7 << invalid;
QTest::newRow("a transform of no size") << "transform" << 7 << invalid;
QTest::newRow("a blank name") << "name= \n\t" << 7 << invalid;
QTest::newRow("a name over 16384 bytes") << "nameLong" << 7 << invalid;
QTest::newRow("a name of 16384 bytes") << "nameFits" << 7 << fine;
QTest::newRow("a shorter name of 16386 bytes") << "nameWide" << 7 << invalid;
QTest::newRow("an image under another name") << "imageName" << 7 << invalid;
QTest::newRow("an image named in lower case") << "imageLower" << 7 << invalid;
QTest::newRow("an active layer that is not there") << "active" << 7 << invalid;
QTest::newRow("letters in colours before version 10") << "textRuns" << 9 << invalid;
QTest::newRow("letters in colours at version 10") << "textRuns" << 10 << fine;
QTest::newRow("one-colour text before version 10") << "text" << 9 << fine;
QTest::newRow("letters in faces before version 11") << "textFonts" << 10 << invalid;
QTest::newRow("letters in faces at version 11") << "textFonts" << 11 << fine;
}
void ProjectManifestTests::validationFollowsTheFormatsVersions()
{
QFETCH(QString, change);
QFETCH(int, version);
QFETCH(std::optional<ProjectError::Kind>, refused);
ProjectSnapshot snapshot = twoLayers();
ProjectManifest &manifest = snapshot.manifest;
manifest.version = version;
manifest.resolution = std::nullopt;
ProjectLayerRecord &image = manifest.layers[0], &blank = manifest.layers[1];
const QString value = change.section('=', 1);
const auto folder = [&] {
blank.isGroup = true;
image.parentID = blank.id;
std::swap(manifest.layers[0], manifest.layers[1]);
};
if (change == "format")
manifest.format = "com.example.project";
else if (change == "colorSpace")
manifest.colorSpace = "Display P3";
else if (change.startsWith("resolution="))
manifest.resolution = value.toDouble();
else if (change.startsWith("width="))
manifest.width = value.toInt();
else if (change.startsWith("height="))
manifest.height = value.toInt();
else if (change.startsWith("layers=")) {
while (int(manifest.layers.size()) < value.toInt())
manifest.layers.push_back({.id = QUuid::createUuid(), .name = "n", .isVisible = true,
.transform = {.origin = {0, 0}, .size = {1, 1}}, .imageFile = std::nullopt});
} else if (change.startsWith("opacity="))
blank.opacity = value.toDouble();
else if (change == "blend")
blank.blendMode = LayerBlendMode::multiply;
else if (change == "mask" || change == "placement" || change == "placementInvalid") {
image.maskFile = uuidString(image.id) + ".mask.png";
image.maskEnabled = true;
snapshot.masks.insert({image.id, asset(gray(4, 4, 255), "mask")});
if (change != "mask")
image.maskPlacement = LayerTransform{.origin = {0, 0}, .size = {change == "placement" ? 5.0 : 0.0, 5}};
} else if (change == "maskName") {
image.maskFile = "mask.png";
snapshot.masks.insert({image.id, asset(gray(4, 4, 255), "mask")});
} else if (change == "maskEnabledAlone")
image.maskEnabled = true;
else if (change == "placementAlone")
image.maskPlacement = LayerTransform{.origin = {0, 0}, .size = {5, 5}};
else if (change == "link")
blank.maskSourceID = image.id;
else if (change == "linkNowhere")
blank.maskSourceID = QUuid::createUuid();
else if (change == "folder")
folder();
else if (change == "folderEmpty")
blank.isGroup = true;
else if (change == "folderBlend") {
folder();
manifest.layers[0].blendMode = LayerBlendMode::screen;
} else if (change == "folderOpacity") {
folder();
manifest.layers[0].opacity = 0.5;
} else if (change == "folderPixels") {
folder();
manifest.layers[0].imageFile = uuidString(manifest.layers[0].id) + ".png";
snapshot.images.insert({manifest.layers[0].id, asset(halfRed(), "folder")});
} else if (change == "folderMask") {
folder();
manifest.layers[0].maskFile = uuidString(manifest.layers[0].id) + ".mask.png";
snapshot.masks.insert({manifest.layers[0].id, asset(gray(4, 4, 255), "mask")});
} else if (change == "parentLayer")
blank.parentID = image.id;
else if (change == "duplicate")
blank.id = image.id;
else if (change == "transform")
blank.transform.size = QSizeF(0, 5);
else if (change.startsWith("name="))
blank.name = value;
else if (change == "nameLong")
blank.name = QString(16'385, 'n');
else if (change == "nameFits")
blank.name = QString(8'192, QChar(0x00E9));
else if (change == "nameWide")
blank.name = QString(8'193, QChar(0x00E9));
else if (change == "imageName")
image.imageFile = "image.png";
else if (change == "imageLower")
image.imageFile = image.id.toString(QUuid::WithoutBraces) + ".png";
else if (change == "text" || change == "textRuns" || change == "textFonts") {
LayerTextStyle style;
style.content = QStringLiteral("Ab");
if (change == "textRuns")
style.colorRuns = std::vector{LayerTextColorRun{1, 1, 1, 0, 0}};
if (change == "textFonts")
style.fontRuns = std::vector{LayerTextFontRun{1, 1, QStringLiteral("DejaVuSansMono")}};
image.text = style;
} else if (change == "active")
manifest.activeLayerID = QUuid::createUuid();
else
QVERIFY(change.isEmpty());
// The same rules guard saving and loading.
QTemporaryDir root;
QCOMPARE(projectError([&] { ProjectStore::save(snapshot, root.filePath("Rules.comp")); }), refused);
ProjectSnapshot plain = twoLayers();
ProjectStore::save(plain, root.filePath("Read.comp"));
for (const auto &[id, mask] : snapshot.masks)
QVERIFY(mask.image().save(root.filePath("Read.comp/images/") + uuidString(id) + ".mask.png"));
for (const auto &[id, pixels] : snapshot.images)
QVERIFY(pixels.image().save(root.filePath("Read.comp/images/") + uuidString(id) + ".png"));
overwrite(root.filePath("Read.comp/manifest.json"), manifest.encoded());
QCOMPARE(projectError([&] { ProjectStore::load(root.filePath("Read.comp")); }), refused);
}
QTEST_MAIN(ProjectManifestTests)
#include "ProjectManifestTests.moc"