#include "Document/ProjectWorkspace.h" #include "Rendering/RasterSnapshot.h" #include "SessionFixtures.h" #include #include #include #include #include // Layers copied from one open project into another. namespace { ImportedImage solid(QColor colour, int alpha, const QString &name) { QImage image(2, 2, QImage::Format_RGBA8888_Premultiplied); colour.setAlpha(alpha); image.fill(colour); return ImportedImage(image, QImage(), name); } // A layer that claims this many pixels and holds one. ImportedImage vast(int width, int height) { QImage dot(1, 1, QImage::Format_RGBA8888_Premultiplied); dot.fill(Qt::red); return ImportedImage(std::make_shared(width, height, dot, QRectF(0, 0, 1, 1), std::vector{}), QImage(), "Vast"); } bool copied(ProjectWorkspace &workspace, QUuid id, std::optional destination, std::optional point = std::nullopt) { bool done = false; workspace.copyLayer(id, destination, point, [&] { done = true; }); return QTest::qWaitFor([&] { return done; }, 10'000); } QStringList names(const EditorSession &session) { QStringList result; for (const ImageLayer &layer : session.document().value().layers) result << layer.name; return result; } } class ProjectCopyTests : public QObject { Q_OBJECT private slots: void whatMayNotBeCopiedChangesNothing(); void theHundredMegapixelsCountAcrossBothProjects(); void aFolderTravelsWithItsContentsAndItsLinks(); void aMaskFromOutsideTheCopyIsBakedIn(); void anAdjustmentLetsGoOfItsBaseUnbaked(); void effectsStayBehindAsSwiftsListLeavesThem(); void theCopyLandsAtThePointOrTheCentre(); void aNewTabGetsTheSourcesCanvasInOneStep(); void bothProjectsAreBusyWhileTheCopyBakes(); void aBakeThatFailsCopiesNothingAndSaysWhy(); void aDroppedRowMayCarryTheNullId(); void ofTwoTabsWithTheLayerTheFirstIsTheSource(); }; void ProjectCopyTests::whatMayNotBeCopiedChangesNothing() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(8, 8); from.session.insert(solid(Qt::red, 255, "Red")); const QUuid id = from.session.activeLayerID().value(); ProjectTab &target = workspace.addTab(); target.session.createDocument(8, 8); target.session.insert(solid(Qt::blue, 255, "Blue")); QSignalSpy changes(&workspace, &ProjectWorkspace::changed); QSignalSpy edits(&target.session, &EditorSession::changed); // A stranger, the layer's own tab, a missing tab. QVERIFY(copied(workspace, QUuid::createUuid(), target.id)); QVERIFY(copied(workspace, id, from.id)); QVERIFY(copied(workspace, id, QUuid::createUuid())); // A source or a target amid a transform. target.session.beginTransform(); QVERIFY(copied(workspace, id, target.id)); target.session.cancelTransform(); from.session.selectLayer(id); from.session.beginTransform(); QVERIFY(copied(workspace, id, target.id)); from.session.cancelTransform(); QCOMPARE(names(target.session), (QStringList{"Blue"})); QCOMPARE(int(workspace.tabs().size()), 2); QCOMPARE(changes.count(), 0); // A target under a sheet or error; a busy front. const qsizetype before = edits.count(); target.session.setShowsImporter(true); QVERIFY(copied(workspace, id, target.id)); target.session.setShowsImporter(false); workspace.select(from.id); target.session.setImportError(QString("earlier")); QVERIFY(target.session.canEditLayers() && copied(workspace, id, target.id)); target.session.setImportError(std::nullopt); workspace.select(target.id); workspace.current().session.setIsProjectBusy(true); QVERIFY(copied(workspace, id, std::nullopt)); workspace.current().session.setIsProjectBusy(false); QCOMPARE(names(target.session), (QStringList{"Blue"})); QCOMPARE(int(workspace.tabs().size()), 2); QCOMPARE(changes.count(), 2); QCOMPARE(edits.count(), before + 6); } void ProjectCopyTests::theHundredMegapixelsCountAcrossBothProjects() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(8, 8); from.session.insert(vast(10'000, 10'000)); const QUuid id = from.session.activeLayerID().value(); ProjectTab &target = workspace.addTab(); target.session.createDocument(8, 8); // Exactly the limit fits. QVERIFY(copied(workspace, id, target.id)); QCOMPARE(names(target.session), (QStringList{"Vast"})); QCOMPARE(target.session.importError(), std::nullopt); // One pixel more, counted across both, does not. target.session.undo(); target.session.insert(solid(Qt::blue, 255, "Dot")); target.session.setRenamingLayerID(std::nullopt); QVERIFY(copied(workspace, id, target.id)); QCOMPARE(names(target.session), (QStringList{"Dot"})); QCOMPARE(target.session.importError(), std::optional(QString("The copied layers exceed this project’s 100-megapixel limit."))); QVERIFY(!workspace.isManaging() && !from.session.isProjectBusy() && !target.session.isProjectBusy()); // While that error shows, the front tab lets nothing start. QVERIFY(!workspace.canSwitch() && copied(workspace, id, std::nullopt)); QCOMPARE(int(workspace.tabs().size()), 2); target.session.setImportError(std::nullopt); // Refused for a new tab, that tab stays. from.session.insert(vast(1, 1)); const QUuid extra = from.session.activeLayerID().value(); from.session.addGroup(); const QUuid folder = from.session.activeLayerID().value(); QVERIFY(from.session.placeLayer(id, folder)); QVERIFY(from.session.placeLayer(extra, folder)); QVERIFY(copied(workspace, folder, std::nullopt)); QCOMPARE(int(workspace.tabs().size()), 3); QVERIFY(!workspace.current().session.document().has_value()); QVERIFY(workspace.current().session.importError().has_value()); } void ProjectCopyTests::aFolderTravelsWithItsContentsAndItsLinks() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(8, 8); from.session.insert(solid(Qt::green, 255, "Stays")); from.session.addGroup(); const QUuid folder = from.session.activeLayerID().value(); from.session.insert(solid(Qt::red, 255, "Base")); const QUuid base = from.session.activeLayerID().value(); from.session.insert(solid(Qt::blue, 255, "Clipped")); const QUuid clipped = from.session.activeLayerID().value(); from.session.toggleClippingMask(clipped); const std::optional before = from.session.document(); ProjectTab &target = workspace.addTab(); target.session.createDocument(8, 8); target.session.insert(solid(Qt::white, 255, "Own")); // Copied from the tab in front into one behind it. workspace.select(from.id); bool switchable = false; connect(&workspace, &ProjectWorkspace::changed, this, [&] { switchable = workspace.canSwitch(); }); QVERIFY(copied(workspace, folder, target.id)); QVERIFY(switchable && workspace.canSwitch()); // The source is untouched; the copy is whole and renumbered. QCOMPARE(from.session.document(), before); QCOMPARE(names(target.session), (QStringList{"Own", "Folder 1", "Base", "Clipped"})); const std::vector layers = target.session.document().value().layers; QVERIFY(layers[1].id != folder && layers[2].id != base && layers[3].id != clipped); QVERIFY(layers[1].isGroup && layers[1].parentID == std::nullopt); QCOMPARE(layers[2].parentID, std::optional(layers[1].id)); QCOMPARE(layers[3].parentID, std::optional(layers[1].id)); QCOMPARE(layers[3].maskSourceID, std::optional(layers[2].id)); // The same pixels: nothing inside the copy needed baking. QVERIFY(layers[3].asset.value().identity() == before.value().layers[3].asset.value().identity()); QCOMPARE(target.session.activeLayerID(), std::optional(layers[1].id)); QCOMPARE(workspace.selectedID(), target.id); QCOMPARE(target.session.history.undoName(), QString("Copy Layers from Project")); // A child copied alone leaves its folder behind. QVERIFY(copied(workspace, base, target.id)); QCOMPARE(target.session.document().value().layers.back().parentID, std::nullopt); // Each copy's watcher goes once it has answered. QTRY_VERIFY(workspace.findChildren().isEmpty()); } void ProjectCopyTests::aMaskFromOutsideTheCopyIsBakedIn() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(2, 2); from.session.insert(solid(Qt::red, 128, "Base")); from.session.insert(solid(Qt::blue, 255, "Top")); const QUuid top = from.session.activeLayerID().value(); from.session.toggleClippingMask(top); const ImageIdentity pixels = from.session.activeLayer().value().asset.value().identity(); ProjectTab &target = workspace.addTab(); target.session.createDocument(2, 2); QVERIFY(copied(workspace, top, target.id)); const ImageLayer copy = target.session.document().value().layers.front(); QCOMPARE(copy.maskSourceID, std::nullopt); QVERIFY(copy.asset.value().identity() != pixels); const QColor pixel = copy.asset.value().image().pixelColor(0, 0); QVERIFY2(std::abs(pixel.alpha() - 128) <= 1, qPrintable(QString::number(pixel.alpha()))); QCOMPARE(pixel.blue(), 255); // The source keeps its link and its own pixels. const ImageLayer kept = layerWith(from.session, top); QVERIFY(kept.maskSourceID.has_value() && kept.asset.value().identity() == pixels); } void ProjectCopyTests::anAdjustmentLetsGoOfItsBaseUnbaked() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(2, 2); from.session.insert(solid(Qt::red, 255, "Base")); from.session.addAdjustment(AdjustmentKind::curves); from.session.setAdjustmentEditingID(std::nullopt); const QUuid adjustment = from.session.activeLayerID().value(); from.session.toggleClippingMask(adjustment); QVERIFY(from.session.activeLayer().value().maskSourceID.has_value()); ProjectTab &target = workspace.addTab(); target.session.createDocument(2, 2); QVERIFY(copied(workspace, adjustment, target.id)); // Settings travel; no pixels to bake the base into. const ImageLayer copy = target.session.document().value().layers.front(); QVERIFY(!copy.maskSourceID && !copy.asset && copy.adjustment == LayerAdjustment{AdjustmentKind::curves}); } void ProjectCopyTests::effectsStayBehindAsSwiftsListLeavesThem() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(2, 2); from.session.insert(solid(Qt::red, 255, "Framed")); LayerEffects effects; effects.stroke = StrokeEffect(); from.session.setEffects(effects); const QUuid framed = from.session.activeLayerID().value(); ProjectTab &target = workspace.addTab(); target.session.createDocument(2, 2); QVERIFY(copied(workspace, framed, target.id)); const ImageLayer copy = target.session.document().value().layers.front(); QVERIFY(copy.name == QString("Framed") && !copy.effects); QVERIFY(layerWith(from.session, framed).effects == effects); } void ProjectCopyTests::theCopyLandsAtThePointOrTheCentre() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(20, 10); from.session.insert(solid(Qt::red, 255, "Red"), QPointF(5, 5)); const QUuid id = from.session.activeLayerID().value(); // A mask placed two to the right rides along. rewrite(from.session, [&](ProjectSnapshot &snapshot) { setMask(snapshot, id, coverage()); record(snapshot, id).maskPlacement = LayerTransform{.origin = {6, 4}, .size = {2, 2}}; }); ProjectTab &target = workspace.addTab(); target.session.createDocument(40, 30); QVERIFY(copied(workspace, id, target.id)); QVERIFY(copied(workspace, id, target.id, QPointF(7, 9))); const std::vector layers = target.session.document().value().layers; QCOMPARE(layers[0].transform.center(), QPointF(20, 15)); QCOMPARE(layers[0].mask.value().placement.value().origin, QPointF(21, 14)); QCOMPARE(layers[1].transform.center(), QPointF(7, 9)); QCOMPARE(layers[1].mask.value().placement.value().origin, QPointF(8, 8)); QCOMPARE(layers[1].mask.value().placement.value().size, QSizeF(2, 2)); // The layer asked for is the anchor, first or not. from.session.addGroup(); const QUuid folder = from.session.activeLayerID().value(); QVERIFY(from.session.placeLayer(id, folder)); rewrite(from.session, [&](ProjectSnapshot &snapshot) { std::reverse(snapshot.manifest.layers.begin(), snapshot.manifest.layers.end()); }); QCOMPARE(from.session.document().value().layers.front().id, id); const QPointF offset = layerWith(from.session, id).transform.center() - layerWith(from.session, folder).transform.center(); QVERIFY(copied(workspace, folder, target.id, QPointF(30, 20))); const std::vector placed = target.session.document().value().layers; QCOMPARE(placed[placed.size() - 1].transform.center(), QPointF(30, 20)); QCOMPARE(placed[placed.size() - 2].transform.center(), QPointF(30, 20) + offset); QVERIFY(!offset.isNull()); } void ProjectCopyTests::aNewTabGetsTheSourcesCanvasInOneStep() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(20, 10); from.session.insert(solid(Qt::red, 255, "Red"), QPointF(5, 5)); const QUuid id = from.session.activeLayerID().value(); QVERIFY(copied(workspace, id, std::nullopt)); QCOMPARE(int(workspace.tabs().size()), 2); EditorSession &made = workspace.current().session; QVERIFY(&made != &from.session); QCOMPARE(made.document().value().size(), QSizeF(20, 10)); QCOMPARE(names(made), (QStringList{"Red"})); QCOMPARE(made.document().value().layers.front().transform.center(), QPointF(10, 5)); QCOMPARE(made.history.undoCount(), 1); QCOMPARE(made.history.undoName(), QString("Copy Layers from Project")); made.undo(); QVERIFY(!made.document().has_value()); } void ProjectCopyTests::bothProjectsAreBusyWhileTheCopyBakes() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(2, 2); // A painted base: its pixels flatten on first use. const auto raster = std::make_shared(2, 2, solid(Qt::red, 128, "").image(), QRectF(0, 0, 2, 2), std::vector{}); from.session.insert(ImportedImage(raster, QImage(), "Base")); from.session.insert(solid(Qt::blue, 255, "Top")); const QUuid top = from.session.activeLayerID().value(); from.session.toggleClippingMask(top); ProjectTab &target = workspace.addTab(); target.session.createDocument(2, 2); // Every worker is held: the bake has to queue. QThreadPool *pool = QThreadPool::globalInstance(); const int workers = pool->maxThreadCount(); QSemaphore held, gate; const auto letGo = qScopeGuard([&] { gate.release(workers); pool->waitForDone(); }); for (int index = 0; index < workers; ++index) { pool->start([&] { held.release(); gate.acquire(); }); } QVERIFY(held.tryAcquire(workers, 10'000)); bool done = false; workspace.copyLayer(top, target.id, std::nullopt, [&] { done = true; }); // Back at once: both busy, nothing baked, nothing placed. QVERIFY(workspace.isManaging() && from.session.isProjectBusy() && target.session.isProjectBusy()); QVERIFY(target.session.document().value().layers.empty() && !done && !raster->hasMaterializedPixels()); gate.release(workers); // No event runs in this wait: a worker flattens. QElapsedTimer clock; clock.start(); while (!raster->hasMaterializedPixels() && clock.elapsed() < 10'000) QTest::qSleep(5); QVERIFY(raster->hasMaterializedPixels() && target.session.isProjectBusy()); QTRY_VERIFY(done); QVERIFY(!workspace.isManaging() && !from.session.isProjectBusy() && !target.session.isProjectBusy()); QCOMPARE(names(target.session), (QStringList{"Top"})); } void ProjectCopyTests::aBakeThatFailsCopiesNothingAndSaysWhy() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(2, 2); from.session.insert(solid(Qt::red, 128, "Base")); const QUuid base = from.session.activeLayerID().value(); // An asset without pixels: no surface can hold it. from.session.insert(ImportedImage(QImage(), QImage(), "Void")); const QUuid hollow = from.session.activeLayerID().value(); QVERIFY(from.session.linkMask(base, hollow)); ProjectTab &target = workspace.addTab(); target.session.createDocument(2, 2); // From the front tab: the last signal sees it free. workspace.select(from.id); bool switchable = false; connect(&workspace, &ProjectWorkspace::changed, this, [&] { switchable = workspace.canSwitch(); }); QTest::ignoreMessage(QtWarningMsg, "cannot copy the layers: The canvas could not be rendered. Try a smaller canvas."); QVERIFY(copied(workspace, hollow, target.id)); QVERIFY(switchable && workspace.canSwitch()); QVERIFY(target.session.document().value().layers.empty()); QCOMPARE(target.session.importError(), std::optional(QString("The canvas could not be rendered. Try a smaller canvas."))); QCOMPARE(target.session.history.undoCount(), 1); QVERIFY(!workspace.isManaging() && !from.session.isProjectBusy() && !target.session.isProjectBusy()); } void ProjectCopyTests::aDroppedRowMayCarryTheNullId() { ProjectWorkspace workspace; ProjectTab &from = workspace.current(); from.session.createDocument(8, 8); from.session.insert(solid(Qt::red, 255, "Zero")); const QUuid old = from.session.activeLayerID().value(); // All zeros is an id a project file may hold. rewrite(from.session, [&](ProjectSnapshot &snapshot) { record(snapshot, old).id = QUuid(); snapshot.manifest.activeLayerID = QUuid(); auto pixels = snapshot.images.extract(old); pixels.key() = QUuid(); snapshot.images.insert(std::move(pixels)); }); QMimeData provider; provider.setData(ProjectWorkspace::layerType, uuidString(QUuid()).toUtf8()); bool done = false; workspace.receiveProviders(provider, std::nullopt, std::nullopt, [&] { done = true; }); QTRY_VERIFY(done); QCOMPARE(int(workspace.tabs().size()), 2); QCOMPARE(names(workspace.current().session), (QStringList{"Zero"})); QVERIFY(!workspace.current().session.activeLayerID().value().isNull()); } void ProjectCopyTests::ofTwoTabsWithTheLayerTheFirstIsTheSource() { ProjectWorkspace workspace; ProjectTab &first = workspace.current(); first.session.createDocument(8, 8); first.session.insert(solid(Qt::red, 255, "One")); const QUuid id = first.session.activeLayerID().value(); // A project saved twice and opened twice shares its ids. ProjectTab &second = workspace.addTab(); ProjectSnapshot twin = first.session.projectSnapshot().value(); record(twin, id).name = "Two"; second.session.installProject(twin, QString("twin.comp")); QVERIFY(copied(workspace, id, std::nullopt)); QCOMPARE(int(workspace.tabs().size()), 3); QCOMPARE(names(workspace.current().session), (QStringList{"One"})); } QTEST_MAIN(ProjectCopyTests) #include "ProjectCopyTests.moc"