#include "BrushFixtures.h" #include "SelectionFixtures.h" #include "Document/EditorSession.h" #include "IO/CanvasResizer.h" #include "IO/ImageResizer.h" #include "IO/ProjectStore.h" #include #include #include #include // Text layers kept and dropped: the manifest, rebuilds, resizes. namespace { // A blank layer, then text saying "Stored" at (20, 30). std::unique_ptr textSession() { auto session = std::make_unique(); session->createDocument(400, 300, true); session->selectTool(NavigationTool::type); session->beginText(QPointF(20, 30), true); TextDraft draft = session->textDraft().value(); draft.style.content = QStringLiteral("Stored"); session->applyText(draft); return session; } QJsonObject textOf(const ProjectSnapshot &snapshot, qsizetype index) { return QJsonDocument::fromJson(snapshot.manifest.encoded()).object().value("layers").toArray().at(index).toObject().value("text").toObject(); } QByteArray withText(const ProjectSnapshot &snapshot, const QJsonValue &text) { QJsonObject object = QJsonDocument::fromJson(snapshot.manifest.encoded()).object(); QJsonArray layers = object.value("layers").toArray(); QJsonObject layer = layers.at(1).toObject(); layer.insert("text", text); layers.replace(1, layer); object.insert("layers", layers); return QJsonDocument(object).toJson(); } bool landed(const std::function)> &start) { bool done = false; start([&] { done = true; }); return QTest::qWaitFor([&] { return done; }, 20000); } } class TypeStoreTests : public QObject { Q_OBJECT private slots: void theManifestKeepsSwiftsKeys(); void theManifestRefusesAWrongStyle(); void anInvalidStyleLoadsAsPixels(); void staleTextIsNeitherLiveNorSaved(); void theStoreRefusesTextItCannotShow(); void rebuildsDropTheText(); void resizersKeepOrDropTheText(); }; void TypeStoreTests::theManifestKeepsSwiftsKeys() { const auto session = textSession(); const ProjectSnapshot point = session->projectSnapshot().value(); QCOMPARE(textOf(point, 1).keys(), (QStringList{"alignment", "blue", "content", "fontName", "fontSize", "green", "leading", "red", "tracking"})); QCOMPARE(textOf(point, 1).value("alignment").toString(), QString("Left")); QCOMPARE(textOf(point, 1).value("fontName").toString(), QString("Helvetica")); session->changeTextStyle([](LayerTextStyle &style) { style.alignment = TextAlignment::center; style.boxSize = QSizeF(300, 150); style.red = 0.25; style.green = 0.5; style.blue = 0.75; style.tracking = -3; style.leading = 90; }); QVERIFY(session->finishText()); const ProjectSnapshot boxed = session->projectSnapshot().value(); QCOMPARE(textOf(boxed, 1).value("boxSize").toArray(), (QJsonArray{300, 150})); const ProjectManifest decoded = ProjectManifest::decoded(boxed.manifest.encoded()); QCOMPARE(decoded.layers[1].text, boxed.manifest.layers[1].text); QCOMPARE(decoded.layers[1].text.value().alignment, TextAlignment::center); QVERIFY(!decoded.layers[0].text); } void TypeStoreTests::theManifestRefusesAWrongStyle() { const auto session = textSession(); const ProjectSnapshot snapshot = session->projectSnapshot().value(); const QJsonObject good = textOf(snapshot, 1); QVERIFY(!ProjectManifest::decoded(withText(snapshot, QJsonValue::Null)).layers[1].text); const auto refused = [&](const QJsonValue &text) { try { ProjectManifest::decoded(withText(snapshot, text)); } catch (const ProjectError &error) { return error.kind == ProjectError::Kind::invalid; } return false; }; for (const char *key : {"content", "fontName", "fontSize", "red", "green", "blue", "alignment", "tracking", "leading"}) { QJsonObject missing = good; missing.remove(key); QVERIFY2(refused(missing), key); } for (const auto &[key, value] : std::vector>{ {"content", 1}, {"fontName", true}, {"fontSize", "72"}, {"red", "0"}, {"green", QJsonArray{}}, {"blue", "x"}, {"alignment", "Justified"}, {"alignment", 0}, {"tracking", "0"}, {"leading", false}, {"boxSize", QJsonArray{300}}}) { QJsonObject wrong = good; wrong.insert(key, value); QVERIFY2(refused(wrong), qPrintable(key)); } QVERIFY(refused(QJsonValue(3))); } void TypeStoreTests::anInvalidStyleLoadsAsPixels() { const auto session = textSession(); const QUuid id = session->activeLayerID().value(); rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).text.value().fontSize = 5000; }); QVERIFY(!layerWith(*session, id).text); QVERIFY(layerWith(*session, id).asset); } void TypeStoreTests::theStoreRefusesTextItCannotShow() { const auto session = textSession(); const ProjectSnapshot good = session->projectSnapshot().value(); const QUuid id = good.manifest.layers[1].id; QTemporaryDir root; const QString path = root.filePath("Text.comp"); ProjectStore::save(good, path); QCOMPARE(ProjectStore::load(path).manifest.layers[1].text, good.manifest.layers[1].text); const auto refused = [&](const std::function &body) -> std::optional { try { body(); } catch (const ProjectError &error) { return error.kind; } return std::nullopt; }; // A style past its bounds refuses the project, both ways. ProjectSnapshot wild = good; record(wild, id).text.value().fontSize = 5000; QCOMPARE(refused([&] { ProjectStore::save(wild, path); }), std::optional(ProjectError::Kind::invalid)); QFile manifest(path + "/manifest.json"); QVERIFY(manifest.open(QIODevice::WriteOnly | QIODevice::Truncate)); manifest.write(wild.manifest.encoded()); manifest.close(); QCOMPARE(refused([&] { ProjectStore::load(path); }), std::optional(ProjectError::Kind::invalid)); // Text needs pixels: a blank layer cannot carry it. ProjectSnapshot bare = good; record(bare, id).imageFile = std::nullopt; bare.images.erase(id); QCOMPARE(refused([&] { ProjectStore::save(bare, path); }), std::optional(ProjectError::Kind::invalid)); record(bare, id).text = std::nullopt; QVERIFY(!refused([&] { ProjectStore::save(bare, path); })); } void TypeStoreTests::staleTextIsNeitherLiveNorSaved() { QImage pixels(20, 10, QImage::Format_RGBA8888_Premultiplied); pixels.fill(Qt::transparent); ImageLayer layer(ImportedImage(pixels, pixels, QStringLiteral("Pixels")), QPointF()); LayerTextStyle style; style.content = QStringLiteral("Stale"); layer.text = LayerText{style, layer.asset.value().identity()}; QVERIFY(layer.liveText()); QCOMPARE(layer.hierarchyRecord().text, std::optional(style)); // Other pixels, as a bake leaves them: text no more. layer.text.value().image = ImageIdentity{}; QVERIFY(!layer.liveText() && layer.text); QVERIFY(!layer.hierarchyRecord().text); } void TypeStoreTests::rebuildsDropTheText() { const std::vector>> edits = { {"Brush Stroke", [](EditorSession &session) { session.selectTool(NavigationTool::brush); session.beginBrush(QPointF(40, 60)); session.finishBrush(); return true; }}, {"Fill", [](EditorSession &session) { session.applySelection(rectPath(QRectF(30, 40, 20, 20)), SelectionMode::replace, "Select"); return landed([&](std::function done) { session.fillSelection(EditorSession::FillSource::foreground, done); }); }}, {"Invert", [](EditorSession &session) { return landed([&](std::function done) { session.invertPixels(done); }); }}, {"Content-Aware Fill", [](EditorSession &session) { session.applySelection(rectPath(QRectF(30, 40, 20, 20)), SelectionMode::replace, "Select"); session.beginFilter(FilterKind::contentAwareFill); return landed([&](std::function done) { session.commitFilter(done); }); }}, {"Transform Selection", [](EditorSession &session) { session.applySelection(rectPath(QRectF(300, 250, 10, 10)), SelectionMode::replace, "Select"); if (!landed([&](std::function done) { session.beginSelectionTransform(done); })) return false; LayerTransform draft = session.transformEdit().value().draft; draft.origin += QPointF(3, 2); session.previewTransform(draft); session.commitTransform(); return true; }}, }; for (const auto &[name, edit] : edits) { const auto session = textSession(); const QUuid id = session->activeLayerID().value(); QVERIFY2(edit(*session), qPrintable(name)); QCOMPARE(session->history.undoName(), name); QVERIFY2(!layerWith(*session, id).text, qPrintable(name)); } // A mask stroke leaves the text as it was. const auto session = textSession(); const QUuid id = session->activeLayerID().value(); session->addLayerMask(true); session->selectLayerTarget(id, true); session->selectTool(NavigationTool::brush); session->beginBrush(QPointF(40, 60)); session->finishBrush(); QVERIFY(layerWith(*session, id).liveText()); } void TypeStoreTests::resizersKeepOrDropTheText() { const auto session = textSession(); const ProjectSnapshot snapshot = session->projectSnapshot().value(); const ProjectSnapshot canvas = CanvasResizer::resize(snapshot, {.width = 500, .height = 400}); QCOMPARE(canvas.manifest.layers[1].text, snapshot.manifest.layers[1].text); QVERIFY(!ImageResizer::resize(snapshot, {.width = 800, .height = 600, .resolution = 72}).manifest.layers[1].text); // Since 1.4.9 the rebuild keeps the text live. session->applyDocumentSize(canvas, QStringLiteral("Canvas Size")); QVERIFY(session->document().value().layers[1].liveText()); QCOMPARE(session->document().value().layers[1].text.value().style, snapshot.manifest.layers[1].text.value()); session->applyImageSize(ImageResizer::resize(session->projectSnapshot().value(), {.width = 1000, .height = 800, .resolution = 72})); QVERIFY(!session->document().value().layers[1].text); } QTEST_MAIN(TypeStoreTests) #include "TypeStoreTests.moc"