#include "Document/LayerAdjustment.h" #include "Document/LayerAppearance.h" #include "Document/LayerTransform.h" #include "IO/ImageExporter.h" #include "IO/ProjectStore.h" #include #include #include #include #include #include #include #include #include // docs/writing-comp-files.md: its examples load, its names are ours. namespace { QString guide() { QFile file(QStringLiteral(QT_TESTCASE_SOURCEDIR "/docs/writing-comp-files.md")); if (!file.open(QIODevice::ReadOnly)) throw std::runtime_error("the guide is missing"); return QString::fromUtf8(file.readAll()); } // The guide's ```json blocks, in order. QList examples() { QList found; static const QRegularExpression block(QStringLiteral("```json\\n(.*?)```"), QRegularExpression::DotMatchesEverythingOption); for (auto match = block.globalMatch(guide()); match.hasNext();) { QJsonParseError error; const QJsonDocument parsed = QJsonDocument::fromJson(match.next().captured(1).toUtf8(), &error); if (error.error != QJsonParseError::NoError) throw std::runtime_error("a guide example is no JSON: " + error.errorString().toStdString()); found << parsed.object(); } return found; } // The backticked names on the line that begins with `lead`. QSet named(const QString &lead) { static const QRegularExpression ticked(QStringLiteral("`([^`]+)`")); for (const QString &line : guide().split(QLatin1Char('\n'))) { if (!line.startsWith(lead)) continue; QSet names; for (auto match = ticked.globalMatch(line.mid(lead.size())); match.hasNext();) names.insert(match.next().captured(1).remove(QLatin1Char('"'))); return names; } throw std::runtime_error("no guide line begins " + lead.toStdString()); } // A project folder: `manifest`, and a PNG per image. QString project(const QTemporaryDir &root, const QJsonObject &manifest) { const QString path = root.filePath(QStringLiteral("Guide.comp")); QDir().mkpath(path + QStringLiteral("/images")); QFile out(path + QStringLiteral("/manifest.json")); if (!out.open(QIODevice::WriteOnly) || out.write(QJsonDocument(manifest).toJson()) < 0) throw std::runtime_error("the manifest was not written"); out.close(); QImage image(1920, 1080, QImage::Format_RGBA8888); image.fill(QColor(20, 30, 60)); for (const QJsonValue &layer : manifest.value(QStringLiteral("layers")).toArray()) { const QString file = layer.toObject().value(QStringLiteral("imageFile")).toString(); if (!file.isEmpty() && !image.save(path + QStringLiteral("/images/") + file, "PNG")) throw std::runtime_error("an image was not written"); } return path; } // The guide's two examples as one project, Curves on top. QJsonObject withCurves() { const QList blocks = examples(); if (blocks.size() != 2) throw std::runtime_error("the guide should hold two examples"); QJsonObject manifest = blocks[0]; QJsonArray layers = manifest.value(QStringLiteral("layers")).toArray(); layers.append(blocks[1]); manifest.insert(QStringLiteral("layers"), layers); return manifest; } // The Curves layer turned `kind`, with `drop` taken out. QJsonObject adjusted(const QString &kind, const QString &drop = QString()) { QJsonObject manifest = withCurves(); QJsonArray layers = manifest.value(QStringLiteral("layers")).toArray(); QJsonObject layer = layers[1].toObject(); QJsonObject adjustment = layer.value(QStringLiteral("adjustment")).toObject(); adjustment.insert(QStringLiteral("kind"), kind); adjustment.remove(drop); layer.insert(QStringLiteral("adjustment"), adjustment); layers[1] = layer; manifest.insert(QStringLiteral("layers"), layers); return manifest; } } class WritingProjectsGuideTests : public QObject { Q_OBJECT private slots: void theMinimalManifestLoadsAsItSays(); void theCurvesLayerWarmsWhatLiesBelow(); void theCommonAdjustmentFieldsAreRequired(); void aFolderPassesAnAdjustmentThrough(); void theBlendModesAndKindsAreOurs(); void hsvSettingsWinOverTheLegacyFields(); }; void WritingProjectsGuideTests::theMinimalManifestLoadsAsItSays() { QTemporaryDir root; const ProjectSnapshot snapshot = ProjectStore::load(project(root, examples().value(0))); QCOMPARE(snapshot.manifest.width, qint64(1920)); QCOMPARE(snapshot.manifest.height, qint64(1080)); QCOMPARE(snapshot.manifest.layers.size(), size_t(1)); const ProjectLayerRecord &layer = snapshot.manifest.layers[0]; QCOMPARE(layer.name, QString("Background")); QVERIFY(layer.isVisible); QCOMPARE(layer.opacity.value(), 1.0); QVERIFY(layer.blendMode.value() == LayerBlendMode::normal); QCOMPARE(layer.transform.origin, QPointF(0, 0)); QCOMPARE(layer.transform.size, QSizeF(1920, 1080)); QCOMPARE(layer.transform.rotation, 0.0); QVERIFY(layer.transform.sampling == LayerSampling::high); // Named after its layer, as the rules say. QCOMPARE(layer.imageFile.value(), layer.id.toString(QUuid::WithoutBraces).toUpper() + QStringLiteral(".png")); QVERIFY(snapshot.images.count(layer.id) == 1); QCOMPARE(snapshot.manifest.activeLayerID.value(), layer.id); } void WritingProjectsGuideTests::theCurvesLayerWarmsWhatLiesBelow() { QTemporaryDir root; const ProjectSnapshot snapshot = ProjectStore::load(project(root, withCurves())); QCOMPARE(snapshot.manifest.layers.size(), size_t(2)); QCOMPARE(snapshot.manifest.layers[1].name, QString("Warm Grade")); const LayerAdjustment &adjustment = snapshot.manifest.layers[1].adjustment.value(); QVERIFY(adjustment.kind == AdjustmentKind::curves); const std::vector> points{{{0, 0}, {255, 255}}, {{0, 0}, {120, 147}, {255, 255}}, {{0, 0}, {100, 114}, {255, 255}}, {{0, 0}, {115, 97}, {255, 238}}}; QCOMPARE(adjustment.curves.channels.size(), points.size()); for (size_t channel = 0; channel < points.size(); ++channel) QCOMPARE(adjustment.curves.channels[channel], points[channel]); // The composite is the background through those curves. QImage below(1, 1, QImage::Format_RGBA8888_Premultiplied); below.fill(QColor(20, 30, 60)); const QColor expected = adjustment.curves.apply(below).pixelColor(0, 0); const QColor rendered = ImageExporter::render(snapshot).image.pixelColor(960, 540); QCOMPARE(rendered, expected); QVERIFY(rendered.red() > 20 && rendered.blue() < 60); } void WritingProjectsGuideTests::theCommonAdjustmentFieldsAreRequired() { for (const QString &field : {QStringLiteral("hue"), QStringLiteral("saturation"), QStringLiteral("lightness"), QStringLiteral("colorize"), QStringLiteral("levels"), QStringLiteral("curves")}) { QTemporaryDir root; QVERIFY_THROWS_EXCEPTION(std::runtime_error, ProjectStore::load(project(root, adjusted(QStringLiteral("Curves"), field)))); } // A kind's own settings may be missing: its defaults. QTemporaryDir root; const ProjectSnapshot blur = ProjectStore::load(project(root, adjusted(QStringLiteral("Gaussian Blur")))); const LayerAdjustment &adjustment = blur.manifest.layers[1].adjustment.value(); QVERIFY(adjustment.kind == AdjustmentKind::gaussianBlur); QCOMPARE(adjustment.gaussianRadius(), 10.0); } void WritingProjectsGuideTests::aFolderPassesAnAdjustmentThrough() { // Red below, and a folder holding only an Invert layer. QJsonObject manifest = adjusted(QStringLiteral("Invert")); QJsonArray layers = manifest.value(QStringLiteral("layers")).toArray(); const QString folder = QStringLiteral("B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E"); QJsonObject group = layers[1].toObject(); group.remove(QStringLiteral("adjustment")); group.insert(QStringLiteral("id"), folder); group.insert(QStringLiteral("name"), QStringLiteral("Folder")); group.insert(QStringLiteral("isGroup"), true); QJsonObject invert = layers[1].toObject(); invert.insert(QStringLiteral("parentID"), folder); manifest.insert(QStringLiteral("layers"), QJsonArray{layers[0], group, invert}); QTemporaryDir root; const QString path = project(root, manifest); QImage red(1920, 1080, QImage::Format_RGBA8888); red.fill(Qt::red); QVERIFY(red.save(path + QStringLiteral("/images/") + layers[0].toObject().value(QStringLiteral("imageFile")).toString(), "PNG")); const QColor shown = ImageExporter::render(ProjectStore::load(path)).image.pixelColor(960, 540); QCOMPARE(shown, QColor(0, 255, 255)); } void WritingProjectsGuideTests::theBlendModesAndKindsAreOurs() { QSet modes, kinds; for (const LayerBlendMode mode : allLayerBlendModes) modes.insert(rawValue(mode)); for (const AdjustmentKind kind : allAdjustmentKinds) kinds.insert(rawValue(kind)); QCOMPARE(named(QStringLiteral("- **Blend modes are spelled exactly** as OmaPhoto names them:")), modes); QCOMPARE(named(QStringLiteral("- `kind` is one of")), kinds); QSet samplings; for (const LayerSampling sampling : {LayerSampling::high, LayerSampling::smooth, LayerSampling::nearest}) samplings.insert(rawValue(sampling)); QCOMPARE(named(QStringLiteral("- `sampling` is")), samplings); } void WritingProjectsGuideTests::hsvSettingsWinOverTheLegacyFields() { // The background's colour, then through each Hue/Saturation. const auto shown = [](int legacyHue, std::optional masterHue) { QJsonObject manifest = adjusted(QStringLiteral("Hue/Saturation")); QJsonArray layers = manifest.value(QStringLiteral("layers")).toArray(); QJsonObject layer = layers[1].toObject(); QJsonObject adjustment = layer.value(QStringLiteral("adjustment")).toObject(); adjustment.insert(QStringLiteral("hue"), legacyHue); if (masterHue) { const QJsonObject values{{"hue", *masterHue}, {"saturation", 0}, {"lightness", 0}}; adjustment.insert(QStringLiteral("hsvSettings"), QJsonObject{{"range", "Master"}, {"colorize", false}, {"invertRange", false}, {"adjustments", QJsonArray{"Master", values}}, {"bands", QJsonArray{}}}); } layer.insert(QStringLiteral("adjustment"), adjustment); layers[1] = layer; manifest.insert(QStringLiteral("layers"), layers); QTemporaryDir root; return ImageExporter::render(ProjectStore::load(project(root, manifest))).image.pixelColor(960, 540); }; const QColor background(20, 30, 60); // No hsvSettings: the legacy hue turns the colour. QVERIFY(shown(120, std::nullopt) != background); // hsvSettings present: the legacy hue is ignored, its own applies. QCOMPARE(shown(120, 0), background); QCOMPARE(shown(0, 120), shown(120, std::nullopt)); } QTEST_GUILESS_MAIN(WritingProjectsGuideTests) #include "WritingProjectsGuideTests.moc"