#include "Document/EditorSession.h" #include "SessionFixtures.h" #include namespace { ImportedImage asset(int width, int height, const QString &name) { return ImportedImage(QImage(width, height, QImage::Format_RGBA8888_Premultiplied), QImage(), name); } QStringList names(const EditorSession &session) { QStringList result; for (const ImageLayer &layer : session.document().value().layers) result << layer.name; return result; } ImageLayer layerNamed(const EditorSession &session, const QString &name) { for (const ImageLayer &layer : session.document().value().layers) { if (layer.name == name) return layer; } throw std::runtime_error("no layer named " + name.toStdString()); } } class EditorSessionTests : public QObject { Q_OBJECT private slots: void settersKeepExactlyWhatTheyAreGiven(); void theSelectionFollowsTheActiveLayer(); void aBusyProjectKeepsItsTool(); void tabSwitchesEachToolsShapeOrMode(); void everyGateClosesLayerEdits_data(); void everyGateClosesLayerEdits(); void theFirstImageMakesTheCanvas(); void imagesLandCentredOnWholePixels(); void newLayersJoinTheActiveFolder(); void foldersAreNamedPlacedAndLimited(); void aWideFolderIsWalkedOnce(); void foldingAFolderTakesTheSelectionFromItsContents(); void deletingAFolderTakesItsContents(); void movingStaysAmongSiblings(); void aNewCanvasCanStartWithALayer(); void sessionChangesAreLogged(); }; void EditorSessionTests::settersKeepExactlyWhatTheyAreGiven() { EditorSession session; const auto flags = [&] { return QList{session.isProjectBusy(), session.showsNewDocument(), session.showsImporter(), session.isImporting()}; }; QCOMPARE(flags(), (QList{false, false, false, false})); QVERIFY(!session.importError().has_value() && !session.renamingLayerID().has_value()); // Each flag is its own. session.setIsProjectBusy(true); QCOMPARE(flags(), (QList{true, false, false, false})); session.setIsProjectBusy(false); session.setShowsNewDocument(true); QCOMPARE(flags(), (QList{false, true, false, false})); session.setShowsNewDocument(false); session.setShowsImporter(true); QCOMPARE(flags(), (QList{false, false, true, false})); session.setShowsImporter(false); session.setIsImporting(true); QCOMPARE(flags(), (QList{false, false, false, true})); session.setIsImporting(false); QCOMPARE(flags(), (QList{false, false, false, false})); const QUuid first = QUuid::createUuid(), second = QUuid::createUuid(); session.setRenamingLayerID(first); session.setImportError(QString("first.png: unreadable")); QCOMPARE(session.renamingLayerID(), std::optional(first)); QCOMPARE(session.importError(), std::optional(QString("first.png: unreadable"))); session.setRenamingLayerID(second); session.setImportError(QString("second.png: too large")); QCOMPARE(session.renamingLayerID(), std::optional(second)); QCOMPARE(session.importError(), std::optional(QString("second.png: too large"))); session.setRenamingLayerID(std::nullopt); session.setImportError(std::nullopt); QVERIFY(!session.importError().has_value() && !session.renamingLayerID().has_value()); QCOMPARE(flags(), (QList{false, false, false, false})); } void EditorSessionTests::theSelectionFollowsTheActiveLayer() { EditorSession session; session.createDocument(64, 64); QCOMPARE(session.selectedLayerIDs(), QSet()); QVERIFY(!session.activeLayer().has_value()); session.addBlankLayer(); const QUuid first = session.activeLayerID().value(); session.addBlankLayer(); QCOMPARE(session.selectedLayerIDs(), QSet{session.activeLayerID().value()}); session.selectLayer(first); QCOMPARE(session.selectedLayerIDs(), QSet{first}); QCOMPARE(session.activeLayer().value().name, QString("Layer 1")); session.selectLayer(std::nullopt); QCOMPARE(session.selectedLayerIDs(), QSet()); // An id the document does not hold selects no layer. session.setActiveLayerID(QUuid::createUuid()); QVERIFY(!session.activeLayer().has_value()); } void EditorSessionTests::aBusyProjectKeepsItsTool() { EditorSession session; QCOMPARE(session.tool(), NavigationTool::move); session.selectTool(NavigationTool::hand); QCOMPARE(session.tool(), NavigationTool::hand); session.setIsProjectBusy(true); session.selectTool(NavigationTool::zoom); QCOMPARE(session.tool(), NavigationTool::hand); } void EditorSessionTests::everyGateClosesLayerEdits_data() { QTest::addColumn("gate"); QTest::newRow("a busy project") << 0; QTest::newRow("an import") << 1; QTest::newRow("the new canvas sheet") << 2; QTest::newRow("the importer") << 3; QTest::newRow("a rename") << 4; } void EditorSessionTests::everyGateClosesLayerEdits() { QFETCH(int, gate); EditorSession session; QVERIFY(!session.canEditLayers()); session.createDocument(64, 64); session.addBlankLayer(); QVERIFY(session.canEditLayers()); const auto set = [&](bool on) { if (gate == 0) session.setIsProjectBusy(on); else if (gate == 1) session.setIsImporting(on); else if (gate == 2) session.setShowsNewDocument(on); else if (gate == 3) session.setShowsImporter(on); else session.setRenamingLayerID(on ? std::optional(QUuid::createUuid()) : std::nullopt); }; set(true); QVERIFY(!session.canEditLayers()); session.addBlankLayer(); QCOMPARE(int(session.document().value().layers.size()), 1); set(false); QVERIFY(session.canEditLayers()); // An import error stops history, not layer edits. session.setImportError(QString("failed")); QVERIFY(session.canEditLayers() && !session.canUseHistory()); } void EditorSessionTests::tabSwitchesEachToolsShapeOrMode() { EditorSession session; session.createDocument(20, 20); session.addBlankLayer(); session.selectTool(NavigationTool::marquee); session.cycleToolMode(); QCOMPARE(session.marqueeKind(), LassoKind::ellipse); session.selectTool(NavigationTool::lasso); session.cycleToolMode(); QCOMPARE(session.lassoKind(), LassoKind::polygonal); session.cycleToolMode(); QCOMPARE(session.lassoKind(), LassoKind::freehand); QCOMPARE(session.marqueeKind(), LassoKind::ellipse); session.selectTool(NavigationTool::brush); session.cycleToolMode(); QCOMPARE(session.brushMode(), BrushToolMode::erase); // Busy or mid-stroke, nothing switches, and nothing is said. QSignalSpy changes(&session, &EditorSession::changed); session.setIsProjectBusy(true); changes.clear(); session.cycleToolMode(); session.setIsProjectBusy(false); session.beginBrush(QPointF(5, 5)); changes.clear(); session.cycleToolMode(); QCOMPARE(changes.count(), 0); QCOMPARE(session.brushMode(), BrushToolMode::erase); session.cancelBrush(); // A tool without modes stays silent. session.selectTool(NavigationTool::hand); changes.clear(); session.cycleToolMode(); QCOMPARE(changes.count(), 0); } void EditorSessionTests::theFirstImageMakesTheCanvas() { EditorSession session; session.viewport.resize(QSizeF(400, 300), 1, std::nullopt); session.viewport.setZoom(3, session.viewport.center(), QSizeF(64, 32)); session.insert(asset(64, 32, "First"), QPointF(999, 999)); const CanvasDocument document = session.document().value(); QCOMPARE(document.size(), QSizeF(64, 32)); QVERIFY(session.viewport.followsFit()); QCOMPARE(session.history.undoName(), QString("Import Image")); // The drop point still places the layer. QCOMPARE(document.layers[0].origin(), QPointF(967, 983)); QCOMPARE(session.activeLayerID(), std::optional(document.layers[0].id)); session.undo(); QVERIFY(!session.document().has_value()); } void EditorSessionTests::imagesLandCentredOnWholePixels() { EditorSession session; session.createDocument(101, 51); session.insert(asset(64, 32, "Centred")); // Half pixels fall to the left and up. QCOMPARE(session.activeLayer().value().origin(), QPointF(18, 9)); QCOMPARE(session.activeLayer().value().size(), QSizeF(64, 32)); // Odd in odd centres exactly: both halves count. session.insert(asset(65, 33, "Odd")); QCOMPARE(session.activeLayer().value().origin(), QPointF(18, 9)); session.insert(asset(64, 33, "Dropped"), QPointF(10.25, -4.5)); QCOMPARE(session.activeLayer().value().origin(), QPointF(-22, -21)); QCOMPARE(names(session), (QStringList{"Centred", "Odd", "Dropped"})); QCOMPARE(session.document().value().size(), QSizeF(101, 51)); } void EditorSessionTests::newLayersJoinTheActiveFolder() { EditorSession session; session.createDocument(64, 64); session.addBlankLayer(); session.addGroup(); const QUuid folder = session.activeLayerID().value(); session.addBlankLayer(); QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); // Beside a layer in the folder, the next joins it. session.addBlankLayer(); QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); session.insert(asset(4, 4, "Image")); QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); session.selectLayer(folder); session.toggleGroupExpansion(folder); QVERIFY(session.collapsedGroupIDs().contains(folder)); // Filling a folded folder opens it, above all it holds. session.addBlankLayer(); QVERIFY(!session.collapsedGroupIDs().contains(folder)); QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2", "Layer 3", "Image", "Layer 4"})); session.selectLayer(folder); session.toggleGroupExpansion(folder); session.insert(asset(4, 4, "Inside")); QVERIFY(!session.collapsedGroupIDs().contains(folder)); QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); // A nested folder's contents count as the outer folder's. session.addGroup(); const QUuid inner = session.activeLayerID().value(); session.addBlankLayer(); QCOMPARE(session.activeLayer().value().parentID, std::optional(inner)); session.selectLayer(folder); session.addBlankLayer(); QCOMPARE(names(session).last(), QString("Layer 6")); QCOMPARE(session.activeLayer().value().parentID, std::optional(folder)); session.selectLayer(std::nullopt); session.insert(asset(4, 4, "Outside")); QCOMPARE(session.activeLayer().value().parentID, std::nullopt); } void EditorSessionTests::foldersAreNamedPlacedAndLimited() { EditorSession session; session.createDocument(64, 64); session.addBlankLayer(); session.addBlankLayer(); session.selectLayer(session.document().value().layers[0].id); session.addGroup(); QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2"})); QCOMPARE(session.history.undoName(), QString("New Folder")); const ImageLayer folder = session.activeLayer().value(); QVERIFY(folder.isGroup && !folder.asset.has_value()); QCOMPARE(folder.size(), QSizeF(64, 64)); QCOMPARE(folder.parentID, std::nullopt); // Each new folder nests in the active one, 64 deep. for (int depth = 0; depth < 70; ++depth) session.addGroup(); QCOMPARE(int(session.document().value().layers.size()), 2 + 64); QCOMPARE(session.activeLayer().value().name, QString("Folder 64")); session.toggleGroupExpansion(folder.id); QCOMPARE(session.activeLayerID(), std::optional(folder.id)); session.addGroup(); QVERIFY(!session.collapsedGroupIDs().contains(folder.id)); // Ten thousand layers leave no room for a folder. EditorSession crowded; crowded.createDocument(8, 8); crowded.beginEdit("Fill"); const ImportedImage dot = asset(1, 1, "Dot"); for (int index = 0; index < 9'999; ++index) crowded.insert(dot); crowded.endEdit(); crowded.addGroup(); QCOMPARE(int(crowded.document().value().layers.size()), 10'000); crowded.addGroup(); QCOMPARE(int(crowded.document().value().layers.size()), 10'000); } void EditorSessionTests::aWideFolderIsWalkedOnce() { EditorSession session; session.createDocument(8, 8); session.addGroup(); const QUuid folder = session.activeLayerID().value(); session.beginEdit("Fill"); const ImportedImage dot = asset(1, 1, "Dot"); for (int index = 0; index < 9'999; ++index) { session.selectLayer(folder); session.insert(dot); } session.endEdit(); // Copying the layers for each child would take minutes. QElapsedTimer timer; timer.start(); QCOMPARE(int(session.descendantIDs(folder).size()), 9'999); QVERIFY2(timer.elapsed() < 2'000, qPrintable(QString::number(timer.elapsed()))); } void EditorSessionTests::foldingAFolderTakesTheSelectionFromItsContents() { EditorSession session; session.createDocument(64, 64); session.addBlankLayer(); const QUuid outside = session.activeLayerID().value(); session.addGroup(); const QUuid folder = session.activeLayerID().value(); session.addGroup(); const QUuid inner = session.activeLayerID().value(); session.addBlankLayer(); const QUuid deep = session.activeLayerID().value(); QCOMPARE(session.descendantIDs(folder), (QSet{inner, deep})); QCOMPARE(session.descendantIDs(inner), QSet{deep}); QCOMPARE(session.descendantIDs(deep), QSet()); QCOMPARE(session.descendantIDs(QUuid::createUuid()), QSet()); QCOMPARE(EditorSession().descendantIDs(folder), QSet()); session.toggleGroupExpansion(folder); QCOMPARE(session.activeLayerID(), std::optional(folder)); QCOMPARE(session.collapsedGroupIDs(), QSet{folder}); session.toggleGroupExpansion(folder); QCOMPARE(session.collapsedGroupIDs(), QSet()); // A selection outside stays; layers and strangers do not fold. session.selectLayer(outside); session.toggleGroupExpansion(inner); QCOMPARE(session.activeLayerID(), std::optional(outside)); session.toggleGroupExpansion(outside); session.toggleGroupExpansion(QUuid::createUuid()); QCOMPARE(session.collapsedGroupIDs(), QSet{inner}); session.setIsProjectBusy(true); session.toggleGroupExpansion(inner); QCOMPARE(session.collapsedGroupIDs(), QSet{inner}); } void EditorSessionTests::deletingAFolderTakesItsContents() { EditorSession session; session.createDocument(64, 64); session.addBlankLayer(); session.addGroup(); const QUuid folder = session.activeLayerID().value(); session.addBlankLayer(); session.addGroup(); session.addBlankLayer(); session.selectLayer(std::nullopt); session.addBlankLayer(); QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2", "Folder 2", "Layer 3", "Layer 4"})); session.selectLayer(layerNamed(session, "Layer 3").id); const int count = session.history.undoCount(); session.deleteLayer(folder); QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 4"})); // The folder sat at index 1; its successor is selected. QCOMPARE(session.activeLayer().value().name, QString("Layer 4")); QCOMPARE(session.history.undoCount(), count + 1); session.undo(); QCOMPARE(int(session.document().value().layers.size()), 6); QCOMPARE(session.activeLayer().value().name, QString("Layer 3")); // Deleting something else leaves the selection alone. session.deleteLayer(layerNamed(session, "Layer 4").id); QCOMPARE(session.activeLayer().value().name, QString("Layer 3")); } void EditorSessionTests::movingStaysAmongSiblings() { EditorSession session; session.createDocument(64, 64); session.addBlankLayer(); session.addGroup(); session.addBlankLayer(); session.addBlankLayer(); session.selectLayer(std::nullopt); session.addBlankLayer(); QCOMPARE(names(session), (QStringList{"Layer 1", "Folder 1", "Layer 2", "Layer 3", "Layer 4"})); // Layer 4's siblings: Layer 1 and the folder, both below. QVERIFY(session.canMoveActiveLayer(-2) && !session.canMoveActiveLayer(-3) && !session.canMoveActiveLayer(1)); session.moveActiveLayer(-1); QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 4", "Layer 2", "Layer 3", "Folder 1"})); // Inside the folder two layers trade places and no more. session.selectLayer(layerNamed(session, "Layer 2").id); QVERIFY(session.canMoveActiveLayer(1) && !session.canMoveActiveLayer(-1) && !session.canMoveActiveLayer(2)); session.moveActiveLayer(1); QCOMPARE(names(session), (QStringList{"Layer 1", "Layer 4", "Layer 3", "Layer 2", "Folder 1"})); session.selectLayer(std::nullopt); QVERIFY(!session.canMoveActiveLayer(0)); } void EditorSessionTests::aNewCanvasCanStartWithALayer() { EditorSession session; session.setShowsNewDocument(true); session.setRenamingLayerID(QUuid::createUuid()); session.createDocument(640, 480, true); const CanvasDocument document = session.document().value(); QCOMPARE(int(document.layers.size()), 1); QCOMPARE(session.activeLayer().value().name, QString("Layer 1")); QVERIFY(!session.activeLayer().value().asset.has_value()); QCOMPARE(session.activeLayer().value().size(), QSizeF(640, 480)); QVERIFY(!session.showsNewDocument() && !session.renamingLayerID().has_value()); QCOMPARE(document.resolution, 72.0); // A plain canvas selects nothing; busy or importing makes none. session.createDocument(10, 10); QVERIFY(!session.activeLayerID().has_value() && session.document().value().layers.empty()); session.setIsProjectBusy(true); session.createDocument(20, 20); session.setIsProjectBusy(false); session.setIsImporting(true); session.createDocument(30, 30); QCOMPARE(session.document().value().size(), QSizeF(10, 10)); } void EditorSessionTests::sessionChangesAreLogged() { EditorSession session; QTest::ignoreMessage(QtInfoMsg, "new canvas 12 x 34"); session.createDocument(12, 34); session.addBlankLayer(); QTest::ignoreMessage(QtInfoMsg, "restored a history snapshot of 0 layers"); session.undo(); } // Alt-dragging a row drops a duplicate, one step. QTEST_GUILESS_MAIN(EditorSessionTests) #include "EditorSessionTests.moc"