449 lines
24 KiB
C++
449 lines
24 KiB
C++
#include "Document/EditorSession.h"
|
|
#include "IO/CanvasResizer.h"
|
|
#include "IO/ImageResizer.h"
|
|
#include "IO/ProjectStore+Json.h"
|
|
#include "ProjectFixtures.h"
|
|
#include <QJsonArray>
|
|
#include <QSignalSpy>
|
|
#include <QTemporaryDir>
|
|
#include <QtTest>
|
|
#include <limits>
|
|
|
|
// Adjustment layers in the document, the manifest and the store.
|
|
namespace {
|
|
constexpr double notANumber = std::numeric_limits<double>::quiet_NaN();
|
|
|
|
// Hue/Saturation with a range and every optional set.
|
|
LayerAdjustment full()
|
|
{
|
|
LayerAdjustment adjustment{.kind = AdjustmentKind::hsv, .hue = 10, .saturation = -20, .lightness = 30, .colorize = true};
|
|
HueSaturationSettings settings(40, 15, -25, true, ColorRange::reds);
|
|
settings.invertRange = true;
|
|
settings.bands[ColorRange::greens] = HueBand{70, 100, 140, 170};
|
|
adjustment.hsvSettings = settings;
|
|
adjustment.levels.channel = LevelsChannel::red;
|
|
adjustment.levels.ranges[1].outputWhite = 200;
|
|
adjustment.curves.channel = LevelsChannel::blue;
|
|
adjustment.curves.channels[2] = {{0, 30}, {255, 255}};
|
|
adjustment.setExposure(ExposureSettings{.exposure = 1, .offset = 0.1, .gamma = 2});
|
|
adjustment.setGradientMap(GradientMapSettings{{1, 0, 0}, {0, 0, 1}, true});
|
|
adjustment.setGrain(GrainSettings{.amount = 40, .size = 3, .roughness = 10, .seed = 9});
|
|
adjustment.setBlackWhite(BlackWhiteSettings{.reds = -50, .magentas = 250, .tint = true, .tintHue = 200, .tintSaturation = 35});
|
|
adjustment.setColorBalance(ColorBalanceSettings{.shadowYellowBlue = -30, .midCyanRed = 20, .highlightMagentaGreen = 90, .preserveLuminosity = false});
|
|
return adjustment;
|
|
}
|
|
|
|
bool refused(const QJsonObject &object)
|
|
{
|
|
try {
|
|
ManifestJson::adjustment(object);
|
|
} catch (const ProjectError &) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// The same rules guard saving and loading.
|
|
std::optional<ProjectError::Kind> stored(const ProjectSnapshot &snapshot)
|
|
{
|
|
QTemporaryDir root;
|
|
const std::optional<ProjectError::Kind> saving = projectError([&] { ProjectStore::save(snapshot, root.filePath("Saved.comp")); });
|
|
ProjectStore::save(twoLayers(), root.filePath("Read.comp"));
|
|
for (const auto &[id, pixels] : snapshot.images) {
|
|
if (!pixels.image().save(root.filePath("Read.comp/images/") + uuidString(id) + ".png"))
|
|
throw std::runtime_error("cannot write a fixture image");
|
|
}
|
|
overwrite(root.filePath("Read.comp/manifest.json"), snapshot.manifest.encoded());
|
|
const std::optional<ProjectError::Kind> loading = projectError([&] { ProjectStore::load(root.filePath("Read.comp")); });
|
|
if (saving != loading)
|
|
throw std::runtime_error("saving and loading judged the project apart");
|
|
return saving;
|
|
}
|
|
|
|
std::unique_ptr<EditorSession> oneLayer()
|
|
{
|
|
auto session = std::make_unique<EditorSession>();
|
|
session->createDocument(4, 4);
|
|
session->insert(asset(halfRed().copy(0, 0, 4, 4), "Red"));
|
|
return session;
|
|
}
|
|
|
|
// Swift's tests clear the request: no editor opens here.
|
|
QUuid added(EditorSession &session, AdjustmentKind kind)
|
|
{
|
|
session.addAdjustment(kind);
|
|
session.setAdjustmentEditingID(std::nullopt);
|
|
return session.activeLayerID().value();
|
|
}
|
|
}
|
|
|
|
class LayerAdjustmentTests : public QObject {
|
|
Q_OBJECT
|
|
private slots:
|
|
void kindsKeepSwiftsNamesAndOrder();
|
|
void validityFollowsSwiftsBounds();
|
|
void theOptionalSettingsFallBackToTheirDefaults();
|
|
void theManifestWritesSwiftsKeys();
|
|
void decodingRefusesWhatSwiftRefuses();
|
|
void aSeedReadsWhatQtsParserReads();
|
|
void theStoreKeepsAdjustmentsToVersion7WithoutPixels();
|
|
void nothingClipsToAnAdjustment();
|
|
void addingPlacesTheLayerAboveTheActiveOne();
|
|
void updatesTakeValidSettingsAlone();
|
|
void adjustmentsTravelWithTheirLayers();
|
|
};
|
|
|
|
void LayerAdjustmentTests::kindsKeepSwiftsNamesAndOrder()
|
|
{
|
|
const std::pair<AdjustmentKind, const char *> kinds[] = {{AdjustmentKind::hsv, "Hue/Saturation"}, {AdjustmentKind::levels, "Levels"},
|
|
{AdjustmentKind::curves, "Curves"}, {AdjustmentKind::exposure, "Exposure"},
|
|
{AdjustmentKind::gradientMap, "Gradient Map"}, {AdjustmentKind::grain, "Grain"},
|
|
{AdjustmentKind::addNoise, "Add Noise"}, {AdjustmentKind::gaussianBlur, "Gaussian Blur"},
|
|
{AdjustmentKind::motionBlur, "Motion Blur"},
|
|
{AdjustmentKind::invert, "Invert"}, {AdjustmentKind::blackWhite, "Black & White"},
|
|
{AdjustmentKind::colorBalance, "Color Balance"}};
|
|
QCOMPARE(allAdjustmentKinds.size(), std::size(kinds));
|
|
for (size_t index = 0; index < std::size(kinds); ++index) {
|
|
const auto &[kind, name] = kinds[index];
|
|
QVERIFY(allAdjustmentKinds[index] == kind);
|
|
QCOMPARE(rawValue(kind), QString(name));
|
|
QVERIFY(adjustmentKind(QString(name)) == kind);
|
|
}
|
|
QVERIFY(!adjustmentKind(QStringLiteral("Vibrance")) && !adjustmentKind(QStringLiteral("levels")) && !adjustmentKind(QStringLiteral(" Levels")));
|
|
// The filter panel edits four; Levels and Hue/Saturation have theirs.
|
|
QVERIFY(filterKind(AdjustmentKind::curves) == FilterKind::curves && filterKind(AdjustmentKind::exposure) == FilterKind::exposure);
|
|
QVERIFY(filterKind(AdjustmentKind::gradientMap) == FilterKind::gradientMap && filterKind(AdjustmentKind::grain) == FilterKind::grain);
|
|
QVERIFY(filterKind(AdjustmentKind::blackWhite) == FilterKind::blackWhite && filterKind(AdjustmentKind::colorBalance) == FilterKind::colorBalance);
|
|
QVERIFY(filterKind(AdjustmentKind::addNoise) == FilterKind::addNoise && filterKind(AdjustmentKind::gaussianBlur) == FilterKind::gaussianBlur);
|
|
QVERIFY(filterKind(AdjustmentKind::motionBlur) == FilterKind::motionBlur);
|
|
QVERIFY(!filterKind(AdjustmentKind::levels) && !filterKind(AdjustmentKind::hsv) && !filterKind(AdjustmentKind::invert));
|
|
// Every kind but Invert opens an editor.
|
|
for (const AdjustmentKind kind : allAdjustmentKinds)
|
|
QCOMPARE(isEditable(kind), kind != AdjustmentKind::invert);
|
|
}
|
|
|
|
void LayerAdjustmentTests::validityFollowsSwiftsBounds()
|
|
{
|
|
QVERIFY(full().isValid() && LayerAdjustment{AdjustmentKind::levels}.isValid());
|
|
// Hue within 360 either way, the others within 100.
|
|
const std::tuple<double, double, double, bool> legacy[] = {
|
|
{360, -100, 100, true}, {-360.5, 0, 0, false}, {0, 100.5, 0, false}, {0, 0, -100.5, false}, {notANumber, 0, 0, false}, {0, 0, notANumber, false}};
|
|
for (const auto &[hue, saturation, lightness, valid] : legacy)
|
|
QCOMPARE((LayerAdjustment{.kind = AdjustmentKind::hsv, .hue = hue, .saturation = saturation, .lightness = lightness}.isValid()), valid);
|
|
// Each range's values too, and every band's handles finite.
|
|
LayerAdjustment ranged = full();
|
|
ranged.hsvSettings->adjustments[ColorRange::blues] = {0, 0, 101};
|
|
QVERIFY(!ranged.isValid());
|
|
ranged = full();
|
|
ranged.hsvSettings->bands[ColorRange::cyans].falloffEnd = std::numeric_limits<double>::infinity();
|
|
QVERIFY(!ranged.isValid());
|
|
// The legacy fields count only when no range settings exist.
|
|
ranged = full();
|
|
ranged.hsvSettings->adjustments[ColorRange::reds] = {-360, -100, -100};
|
|
QVERIFY(ranged.isValid());
|
|
// Levels as normalized, then every kind's own rules.
|
|
LayerAdjustment levels{AdjustmentKind::hsv};
|
|
levels.levels.ranges[3].white = levels.levels.ranges[3].black;
|
|
QVERIFY(!levels.isValid());
|
|
LayerAdjustment curves{AdjustmentKind::hsv};
|
|
curves.curves.channels[1] = {{0, 0}};
|
|
QVERIFY(!curves.isValid());
|
|
LayerAdjustment exposure{AdjustmentKind::hsv};
|
|
exposure.setExposure(ExposureSettings{.gamma = 0});
|
|
QVERIFY(!exposure.isValid());
|
|
LayerAdjustment map{AdjustmentKind::hsv};
|
|
map.setGradientMap(GradientMapSettings{{1.5, 0, 0}, {1, 1, 1}, false});
|
|
QVERIFY(!map.isValid());
|
|
LayerAdjustment grain{AdjustmentKind::hsv};
|
|
grain.setGrain(GrainSettings{.amount = 101});
|
|
QVERIFY(!grain.isValid());
|
|
}
|
|
|
|
void LayerAdjustmentTests::theOptionalSettingsFallBackToTheirDefaults()
|
|
{
|
|
LayerAdjustment adjustment{.kind = AdjustmentKind::hsv, .hue = 120, .saturation = 10, .lightness = -5, .colorize = true};
|
|
QVERIFY(!adjustment.exposureSettings && !adjustment.gradientMapSettings && !adjustment.grainSettings);
|
|
QVERIFY(adjustment.exposure() == ExposureSettings() && adjustment.gradientMap() == GradientMapSettings() && adjustment.grain() == GrainSettings());
|
|
// Without range settings Hue/Saturation reads the legacy four.
|
|
QVERIFY(adjustment.resolvedHSV() == HueSaturationSettings(120, 10, -5, true));
|
|
adjustment.hsvSettings = HueSaturationSettings(30);
|
|
QVERIFY(adjustment.resolvedHSV() == HueSaturationSettings(30));
|
|
adjustment.setExposure(ExposureSettings{.exposure = 2});
|
|
adjustment.setGradientMap(GradientMapSettings{{0, 1, 0}, {1, 1, 1}, false});
|
|
adjustment.setGrain(GrainSettings{.seed = 5});
|
|
QCOMPARE(adjustment.exposureSettings.value().exposure, 2.0);
|
|
QVERIFY(adjustment.gradientMap().shadows == AdjustmentColor(0, 1, 0) && adjustment.grainSettings.value().seed == 5);
|
|
}
|
|
|
|
void LayerAdjustmentTests::theManifestWritesSwiftsKeys()
|
|
{
|
|
const QJsonObject object = ManifestJson::encoded(full());
|
|
QCOMPARE(object.keys(), (QStringList{"blackWhiteSettings", "colorBalanceSettings", "colorize", "curves", "exposureSettings", "gradientMapSettings",
|
|
"grainSettings", "hsvSettings", "hue", "kind", "levels", "lightness", "saturation"}));
|
|
QCOMPARE(object.value("kind").toString(), QString("Hue/Saturation"));
|
|
const QJsonObject hsv = object.value("hsvSettings").toObject();
|
|
QCOMPARE(hsv.keys(), (QStringList{"adjustments", "bands", "colorize", "invertRange", "range"}));
|
|
QCOMPARE(hsv.value("range").toString(), QString("Reds"));
|
|
// A dictionary keyed by ColorRange: key and value in turn.
|
|
const QJsonArray adjustments = hsv.value("adjustments").toArray();
|
|
QVERIFY(adjustments.size() == 2 && adjustments.at(0).toString() == QString("Reds"));
|
|
QCOMPARE(adjustments.at(1).toObject(), (QJsonObject{{"hue", 40}, {"saturation", 15}, {"lightness", -25}}));
|
|
const QJsonArray bands = hsv.value("bands").toArray();
|
|
QVERIFY(bands.size() == 14 && bands.at(0).toString() == QString("Master"));
|
|
QCOMPARE(bands.at(3).toObject(), (QJsonObject{{"falloffStart", 315}, {"rangeStart", 345}, {"rangeEnd", 15}, {"falloffEnd", 45}}));
|
|
const QJsonObject levels = object.value("levels").toObject();
|
|
QVERIFY(levels.value("channel").toString() == QString("Red") && levels.value("ranges").toArray().size() == 4);
|
|
QCOMPARE(levels.value("ranges").toArray().at(1).toObject(),
|
|
(QJsonObject{{"black", 0}, {"gamma", 1}, {"white", 255}, {"outputBlack", 0}, {"outputWhite", 200}}));
|
|
const QJsonObject curves = object.value("curves").toObject();
|
|
QVERIFY(curves.value("channel").toString() == QString("Blue") && curves.value("channels").toArray().size() == 4);
|
|
QCOMPARE(curves.value("channels").toArray().at(2).toArray().at(0).toObject(), (QJsonObject{{"x", 0}, {"y", 30}}));
|
|
QCOMPARE(object.value("exposureSettings").toObject(), (QJsonObject{{"exposure", 1}, {"offset", 0.1}, {"gamma", 2}}));
|
|
QCOMPARE(object.value("gradientMapSettings").toObject(),
|
|
(QJsonObject{{"shadows", QJsonObject{{"red", 1}, {"green", 0}, {"blue", 0}}}, {"highlights", QJsonObject{{"red", 0}, {"green", 0}, {"blue", 1}}},
|
|
{"reversed", true}}));
|
|
QCOMPARE(object.value("grainSettings").toObject(), (QJsonObject{{"amount", 40}, {"size", 3}, {"roughness", 10}, {"seed", 9}}));
|
|
QVERIFY(ManifestJson::adjustment(object) == full());
|
|
// Through the whole manifest, beside the other keys.
|
|
ProjectManifest manifest = twoLayers().manifest;
|
|
manifest.layers[1].adjustment = full();
|
|
const ProjectManifest decoded = ProjectManifest::decoded(manifest.encoded());
|
|
QVERIFY(decoded.layers[1].adjustment == full() && !decoded.layers[0].adjustment);
|
|
}
|
|
|
|
void LayerAdjustmentTests::decodingRefusesWhatSwiftRefuses()
|
|
{
|
|
const QJsonObject good = ManifestJson::encoded(full());
|
|
QVERIFY(!refused(good));
|
|
const auto without = [&good](const char *key) {
|
|
QJsonObject object = good;
|
|
object.remove(QLatin1String(key));
|
|
return object;
|
|
};
|
|
const auto with = [&good](const char *key, const QJsonValue &value) {
|
|
QJsonObject object = good;
|
|
object.insert(QLatin1String(key), value);
|
|
return object;
|
|
};
|
|
// Synthesized decoding needs every key but the optionals.
|
|
for (const char *key : {"kind", "hue", "saturation", "lightness", "colorize", "levels", "curves"})
|
|
QVERIFY2(refused(without(key)), key);
|
|
for (const char *key : {"hsvSettings", "exposureSettings", "gradientMapSettings", "grainSettings", "blackWhiteSettings", "colorBalanceSettings"}) {
|
|
QVERIFY2(!refused(without(key)) && !refused(with(key, QJsonValue::Null)), key);
|
|
QVERIFY2(refused(with(key, QJsonArray())), key);
|
|
}
|
|
QVERIFY(refused(with("kind", "Vibrance")) && refused(with("hue", "10")) && refused(with("colorize", 1)));
|
|
// Pairs come whole, with known ranges, in an array.
|
|
const auto hsv = [&](const char *key, const QJsonValue &value) {
|
|
QJsonObject settings = good.value("hsvSettings").toObject();
|
|
settings.insert(QLatin1String(key), value);
|
|
return with("hsvSettings", settings);
|
|
};
|
|
QVERIFY(refused(hsv("adjustments", QJsonArray{"Reds"})) && refused(hsv("adjustments", QJsonArray{"Pinks", QJsonObject{{"hue", 1}, {"saturation", 0}, {"lightness", 0}}})));
|
|
QVERIFY(refused(hsv("adjustments", QJsonObject())) && refused(hsv("range", "Violets")));
|
|
QVERIFY(!refused(hsv("adjustments", QJsonArray())));
|
|
// Later pairs win, as a Swift dictionary assigns.
|
|
const QJsonObject twice = hsv("adjustments", QJsonArray{"Reds", QJsonObject{{"hue", 1}, {"saturation", 0}, {"lightness", 0}}, "Reds",
|
|
QJsonObject{{"hue", 2}, {"saturation", 0}, {"lightness", 0}}});
|
|
QCOMPARE(ManifestJson::adjustment(twice).hsvSettings.value().adjustments.at(ColorRange::reds).hue, 2.0);
|
|
// Levels holds four ranges; channels are Swift's names.
|
|
QJsonObject levels = good.value("levels").toObject();
|
|
QJsonArray ranges = levels.value("ranges").toArray();
|
|
ranges.append(ranges.first());
|
|
levels.insert("ranges", ranges);
|
|
QVERIFY(refused(with("levels", levels)));
|
|
ranges.removeLast();
|
|
ranges.removeLast();
|
|
levels.insert("ranges", ranges);
|
|
QVERIFY(refused(with("levels", levels)));
|
|
levels = good.value("levels").toObject();
|
|
levels.insert("channel", "Alpha");
|
|
QVERIFY(refused(with("levels", levels)));
|
|
QJsonObject curves = good.value("curves").toObject();
|
|
curves.insert("channels", QJsonObject());
|
|
QVERIFY(refused(with("curves", curves)));
|
|
// A seed is Swift's UInt32.
|
|
const auto seeded = [&](const QJsonValue &seed) {
|
|
QJsonObject grain = good.value("grainSettings").toObject();
|
|
grain.insert("seed", seed);
|
|
return with("grainSettings", grain);
|
|
};
|
|
QVERIFY(refused(seeded(-1)) && refused(seeded(qint64(4'294'967'296))) && refused(seeded(1.5)));
|
|
QCOMPARE(ManifestJson::adjustment(seeded(qint64(4'294'967'295))).grainSettings.value().seed, 4'294'967'295u);
|
|
}
|
|
|
|
void LayerAdjustmentTests::aSeedReadsWhatQtsParserReads()
|
|
{
|
|
ProjectManifest manifest = twoLayers().manifest;
|
|
manifest.layers[1].adjustment = full();
|
|
const QByteArray json = manifest.encoded();
|
|
QVERIFY(json.contains("\"seed\": 9"));
|
|
const auto seed = [&json](const char *token) {
|
|
QByteArray changed = json;
|
|
changed.replace("\"seed\": 9", QByteArray("\"seed\": ") + token);
|
|
return ProjectManifest::decoded(changed).layers[1].adjustment.value().grainSettings.value().seed;
|
|
};
|
|
// Whole numbers written 5.0 or 5e0 read as Swift's.
|
|
QCOMPARE(seed("5.0"), 5u);
|
|
QCOMPARE(seed("5e0"), 5u);
|
|
// Qt's parser fails a token that underflows; Swift reads zero.
|
|
QCOMPARE(projectError([&] { seed("1e-400"); }), ProjectError::Kind::invalid);
|
|
}
|
|
|
|
void LayerAdjustmentTests::theStoreKeepsAdjustmentsToVersion7WithoutPixels()
|
|
{
|
|
ProjectSnapshot snapshot = twoLayers();
|
|
ProjectLayerRecord &blank = snapshot.manifest.layers[1];
|
|
blank.adjustment = full();
|
|
QCOMPARE(stored(snapshot), std::nullopt);
|
|
snapshot.manifest.version = 6;
|
|
QCOMPARE(stored(snapshot), ProjectError::Kind::invalid);
|
|
snapshot.manifest.version = 7;
|
|
// Pixels, a folder or invalid settings refuse it.
|
|
blank.imageFile = uuidString(blank.id) + ".png";
|
|
snapshot.images.insert({blank.id, asset(halfRed(), "pixels")});
|
|
QCOMPARE(stored(snapshot), ProjectError::Kind::invalid);
|
|
blank.imageFile = std::nullopt;
|
|
snapshot.images.erase(blank.id);
|
|
blank.isGroup = true;
|
|
QCOMPARE(stored(snapshot), ProjectError::Kind::invalid);
|
|
blank.isGroup = std::nullopt;
|
|
blank.adjustment->hue = 400;
|
|
QCOMPARE(stored(snapshot), ProjectError::Kind::invalid);
|
|
}
|
|
|
|
void LayerAdjustmentTests::nothingClipsToAnAdjustment()
|
|
{
|
|
ProjectSnapshot snapshot = twoLayers();
|
|
ProjectLayerRecord &image = snapshot.manifest.layers[0], &blank = snapshot.manifest.layers[1];
|
|
// An adjustment clips to a layer; nothing clips to it.
|
|
blank.adjustment = full();
|
|
blank.maskSourceID = image.id;
|
|
QCOMPARE(stored(snapshot), std::nullopt);
|
|
blank.maskSourceID = std::nullopt;
|
|
std::swap(snapshot.manifest.layers[0], snapshot.manifest.layers[1]);
|
|
snapshot.manifest.layers[1].maskSourceID = snapshot.manifest.layers[0].id;
|
|
QCOMPARE(stored(snapshot), ProjectError::Kind::invalid);
|
|
// The session will not link a layer to one either.
|
|
const std::unique_ptr<EditorSession> session = oneLayer();
|
|
const QUuid red = session->activeLayerID().value();
|
|
const QUuid levels = added(*session, AdjustmentKind::levels);
|
|
QVERIFY(session->canLinkMask(red, levels) && !session->canLinkMask(levels, red));
|
|
}
|
|
|
|
void LayerAdjustmentTests::addingPlacesTheLayerAboveTheActiveOne()
|
|
{
|
|
const std::unique_ptr<EditorSession> session = oneLayer();
|
|
session->setForegroundColor(PaletteColor{1, 0, 0});
|
|
session->setBackgroundColor(PaletteColor{0, 0, 1});
|
|
session->addBlankLayer();
|
|
const QUuid top = session->activeLayerID().value();
|
|
session->selectLayer(session->document().value().layers[0].id);
|
|
const int steps = session->history.undoCount();
|
|
session->addAdjustment(AdjustmentKind::gradientMap);
|
|
// Above the active layer, blank, the canvas's size.
|
|
const std::vector<ImageLayer> &layers = session->document().value().layers;
|
|
QCOMPARE(layers.size(), size_t(3));
|
|
const ImageLayer &made = layers[1];
|
|
QVERIFY(made.id == session->activeLayerID() && layers[2].id == top && !made.asset && !made.parentID);
|
|
QVERIFY(made.name == QString("Gradient Map") && made.transform.size == QSizeF(4, 4));
|
|
QVERIFY(session->history.undoCount() == steps + 1 && session->history.undoName() == QString("New Gradient Map Adjustment"));
|
|
// Its editor is asked for; Swift's panel then opens it.
|
|
QCOMPARE(session->adjustmentEditingID(), std::optional(made.id));
|
|
session->setAdjustmentEditingID(std::nullopt);
|
|
// A Gradient Map runs from the foreground to the background.
|
|
const LayerAdjustment &map = made.adjustment.value();
|
|
QVERIFY(map.kind == AdjustmentKind::gradientMap && map.gradientMap().shadows == AdjustmentColor(1, 0, 0));
|
|
QVERIFY(map.gradientMap().highlights == AdjustmentColor(0, 0, 1) && !map.grainSettings);
|
|
// Each Grain layer rolls its own pattern; others keep defaults.
|
|
added(*session, AdjustmentKind::grain);
|
|
const quint32 first = session->activeLayer().value().adjustment.value().grain().seed;
|
|
added(*session, AdjustmentKind::grain);
|
|
QVERIFY(session->activeLayer().value().adjustment.value().grain().seed != first);
|
|
added(*session, AdjustmentKind::curves);
|
|
QVERIFY(session->activeLayer().value().adjustment.value() == LayerAdjustment{AdjustmentKind::curves});
|
|
// A folder takes it inside, uncollapsed.
|
|
session->selectLayer(top);
|
|
session->groupSelectedLayers();
|
|
const QUuid folder = session->activeLayerID().value();
|
|
session->toggleGroupExpansion(folder);
|
|
QVERIFY(session->collapsedGroupIDs().contains(folder));
|
|
added(*session, AdjustmentKind::levels);
|
|
QVERIFY(session->activeLayer().value().parentID == folder && !session->collapsedGroupIDs().contains(folder));
|
|
// Beside a layer in a folder, it joins that folder.
|
|
session->selectLayer(top);
|
|
added(*session, AdjustmentKind::exposure);
|
|
QVERIFY(session->activeLayer().value().parentID == folder);
|
|
// Ten thousand layers are the most a project holds.
|
|
ProjectSnapshot crowded = twoLayers();
|
|
while (crowded.manifest.layers.size() < 9'999)
|
|
crowded.manifest.layers.push_back({.id = QUuid::createUuid(), .name = "n", .isVisible = true,
|
|
.transform = {.origin = {0, 0}, .size = {1, 1}}, .imageFile = std::nullopt});
|
|
EditorSession packed;
|
|
packed.installProject(crowded, QString());
|
|
added(packed, AdjustmentKind::levels);
|
|
packed.addAdjustment(AdjustmentKind::levels);
|
|
QCOMPARE(packed.document().value().layers.size(), size_t(10'000));
|
|
// Without a document or with the layers busy, nothing.
|
|
EditorSession empty;
|
|
empty.addAdjustment(AdjustmentKind::levels);
|
|
QVERIFY(!empty.document());
|
|
session->setIsProjectBusy(true);
|
|
const size_t count = session->document().value().layers.size();
|
|
session->addAdjustment(AdjustmentKind::levels);
|
|
QCOMPARE(session->document().value().layers.size(), count);
|
|
}
|
|
|
|
void LayerAdjustmentTests::updatesTakeValidSettingsAlone()
|
|
{
|
|
const std::unique_ptr<EditorSession> session = oneLayer();
|
|
const QUuid id = added(*session, AdjustmentKind::levels);
|
|
const int steps = session->history.undoCount(), revision = session->brushRevision();
|
|
QSignalSpy changed(session.get(), &EditorSession::changed);
|
|
LayerAdjustment value{AdjustmentKind::levels};
|
|
value.levels.ranges[0].outputWhite = 0;
|
|
session->updateAdjustment(id, value);
|
|
// No step of its own; the canvas hears of it.
|
|
QVERIFY(session->activeLayer().value().adjustment == value && session->history.undoCount() == steps);
|
|
QVERIFY(session->brushRevision() == revision + 1 && changed.count() == 1);
|
|
// Invalid settings, or a missing layer, change nothing.
|
|
LayerAdjustment wild = value;
|
|
wild.hue = 1000;
|
|
session->updateAdjustment(id, wild);
|
|
session->updateAdjustment(QUuid::createUuid(), LayerAdjustment{AdjustmentKind::levels});
|
|
QVERIFY(session->activeLayer().value().adjustment == value && changed.count() == 1);
|
|
// An adjustment layer paints nothing; its mask still paints.
|
|
QVERIFY(!session->canPaint());
|
|
session->addMask();
|
|
QVERIFY(session->isMaskSelected() && session->canPaint());
|
|
}
|
|
|
|
void LayerAdjustmentTests::adjustmentsTravelWithTheirLayers()
|
|
{
|
|
const std::unique_ptr<EditorSession> session = oneLayer();
|
|
const QUuid id = added(*session, AdjustmentKind::hsv);
|
|
session->updateAdjustment(id, full());
|
|
// The record, the store and the install carry it whole.
|
|
QVERIFY(session->activeLayer().value().hierarchyRecord().adjustment == full());
|
|
QTemporaryDir root;
|
|
ProjectStore::save(session->projectSnapshot().value(), root.filePath("Adjusted.comp"));
|
|
const ProjectSnapshot loaded = ProjectStore::load(root.filePath("Adjusted.comp"));
|
|
EditorSession restored;
|
|
restored.installProject(loaded, root.filePath("Adjusted.comp"));
|
|
QVERIFY(restored.document().value().layers[1].adjustment == full());
|
|
// Both resizers pass it on, as Swift's rebuilds list it.
|
|
const ProjectSnapshot resized = ImageResizer::resize(loaded, ImageSizeOptions{.width = 8, .height = 8, .resolution = 72});
|
|
QVERIFY(resized.manifest.layers[1].adjustment == full());
|
|
const ProjectSnapshot extended = CanvasResizer::resize(loaded, CanvasSizeOptions{.width = 6, .height = 5});
|
|
QVERIFY(extended.manifest.layers[1].adjustment == full());
|
|
// A duplicate keeps it; the layer's equality reads it.
|
|
session->duplicateActiveLayer();
|
|
QVERIFY(session->activeLayer().value().adjustment == full());
|
|
ImageLayer changed = session->activeLayer().value();
|
|
changed.adjustment->hue = 11;
|
|
QVERIFY(!(changed == session->activeLayer().value()));
|
|
}
|
|
|
|
QTEST_MAIN(LayerAdjustmentTests)
|
|
#include "LayerAdjustmentTests.moc"
|