Files
OmaPhoto/tests/HistoryTests.cpp
T

462 lines
17 KiB
C++

#include "Document/DocumentHistory.h"
#include "Document/EditorSession.h"
#include <QtTest>
#include <functional>
namespace {
ImportedImage asset(int width, int height, const QString &name)
{
QImage image(width, height, QImage::Format_RGBA8888_Premultiplied);
image.fill(Qt::red);
QImage thumbnail(8, 8, QImage::Format_RGBA8888_Premultiplied);
thumbnail.fill(Qt::red);
return ImportedImage(image, thumbnail, name);
}
std::optional<CanvasDocument> documentNamed(const QString &layerName)
{
CanvasDocument document(64, 32);
document.layers.push_back(ImageLayer(layerName, document.size()));
return document;
}
}
class HistoryTests : public QObject {
Q_OBJECT
private slots:
void undoAndRedoReturnTheRecordedSnapshots();
void nestedTransactionsRecordOneEntry();
void noOpEditsPreserveRedo();
void aNewEditClearsRedo();
void savedRevisionFollowsUndoAndRedo();
void endWithoutBeginIsIgnored();
void resetClearsEverything();
void historyBoundsEntriesAndUniqueRetainedPixels();
void retainedBytesCountEachHistoryOnlyImageOnce();
void byteLimitDropsTheOldestPastEntryFirst();
void undoTrimsRedoWhenItsPixelsExceedTheLimit();
void undoTrimsThePastBeforeTheRedoStack();
void negativeLimitsBecomeZero();
void everyLayerEditRoundTripsWithSelection();
void navigationNoOpsAndSaveRevisionPreserveHistory();
void replacementCanvasAndNestedTransactionsUndoAsOne();
void historyBlockedDuringImportsAndDialogs();
};
void HistoryTests::undoAndRedoReturnTheRecordedSnapshots()
{
DocumentHistory history;
const std::optional<CanvasDocument> before = documentNamed("Before");
std::optional<CanvasDocument> after = before;
after.value().layers[0].name = "After";
const QUuid selection = before.value().layers[0].id;
history.begin("Rename Layer", before, std::nullopt);
history.end(after, selection);
QCOMPARE(history.undoCount(), 1);
QCOMPARE(history.undoName(), QString("Rename Layer"));
QCOMPARE(history.redoName(), QString());
QVERIFY(history.canUndo() && !history.canRedo());
const DocumentHistory::Snapshot undone = history.undo().value();
QVERIFY(undone.document == before);
QCOMPARE(undone.activeLayerID, std::nullopt);
QCOMPARE(history.undoName(), QString());
QCOMPARE(history.redoName(), QString("Rename Layer"));
QVERIFY(!history.canUndo() && history.canRedo());
QVERIFY(!history.undo().has_value());
const DocumentHistory::Snapshot redone = history.redo().value();
QVERIFY(redone.document == after);
QCOMPARE(redone.activeLayerID, std::optional<QUuid>(selection));
QVERIFY(history.canUndo() && !history.canRedo());
QVERIFY(!history.redo().has_value());
QVERIFY(!undone.revision.isNull() && !redone.revision.isNull());
QVERIFY(undone.revision != redone.revision);
history.begin("Hide Layer", after, selection);
history.end(std::nullopt, std::nullopt);
const DocumentHistory::Snapshot second = history.undo().value();
QCOMPARE(second.revision, redone.revision);
}
void HistoryTests::nestedTransactionsRecordOneEntry()
{
DocumentHistory history;
const std::optional<CanvasDocument> start = documentNamed("A");
std::optional<CanvasDocument> edited = start;
history.begin("First", start, std::nullopt);
history.end(documentNamed("Earlier"), std::nullopt);
QVERIFY(history.canUndo());
history.undo();
QVERIFY(history.canRedo());
history.begin("Layer Setup", start, std::nullopt);
QVERIFY(!history.canRedo());
QVERIFY(!history.redo().has_value());
history.end(start, std::nullopt);
history.redo();
history.begin("Layer Setup", start, std::nullopt);
QVERIFY(!history.canUndo());
edited.value().layers[0].name = "B";
history.begin("Inner", edited, std::nullopt);
edited.value().layers[0].name = "C";
history.end(edited, std::nullopt);
QCOMPARE(history.undoCount(), 1);
QVERIFY(!history.canUndo());
QVERIFY(!history.undo().has_value());
history.end(edited, std::nullopt);
QCOMPARE(history.undoCount(), 2);
QCOMPARE(history.undoName(), QString("Layer Setup"));
QVERIFY(history.undo().value().document == start);
}
void HistoryTests::noOpEditsPreserveRedo()
{
DocumentHistory history;
const std::optional<CanvasDocument> start = documentNamed("A");
std::optional<CanvasDocument> renamed = start;
renamed.value().layers[0].name = "B";
history.begin("Rename Layer", start, std::nullopt);
history.end(renamed, std::nullopt);
history.undo();
history.begin("Rename Layer", start, std::nullopt);
history.end(start, start.value().layers[0].id);
QCOMPARE(history.undoCount(), 0);
QVERIFY(history.canRedo());
QVERIFY(!history.isModified());
}
void HistoryTests::aNewEditClearsRedo()
{
DocumentHistory history;
const std::optional<CanvasDocument> start = documentNamed("A");
history.begin("Rename Layer", start, std::nullopt);
history.end(documentNamed("B"), std::nullopt);
history.undo();
QVERIFY(history.canRedo());
history.begin("Delete Layer", start, std::nullopt);
history.end(std::nullopt, std::nullopt);
QVERIFY(!history.canRedo());
QCOMPARE(history.undoName(), QString("Delete Layer"));
}
void HistoryTests::savedRevisionFollowsUndoAndRedo()
{
DocumentHistory history;
QVERIFY(!history.isModified());
const std::optional<CanvasDocument> start = documentNamed("A");
history.begin("Rename Layer", start, std::nullopt);
history.end(documentNamed("B"), std::nullopt);
QVERIFY(history.isModified());
history.markSaved();
QVERIFY(!history.isModified());
history.undo();
QVERIFY(history.isModified());
history.redo();
QVERIFY(!history.isModified());
history.undo();
history.markSaved();
history.redo();
QVERIFY(history.isModified());
}
void HistoryTests::endWithoutBeginIsIgnored()
{
DocumentHistory history;
history.end(documentNamed("A"), std::nullopt);
QCOMPARE(history.undoCount(), 0);
QVERIFY(!history.isModified());
history.begin("Rename Layer", documentNamed("A"), std::nullopt);
history.end(documentNamed("B"), std::nullopt);
QCOMPARE(history.undoCount(), 1);
}
void HistoryTests::resetClearsEverything()
{
DocumentHistory history;
const std::optional<CanvasDocument> start = documentNamed("A");
history.begin("One", start, std::nullopt);
history.end(documentNamed("B"), std::nullopt);
history.begin("Two", start, std::nullopt);
history.end(documentNamed("C"), std::nullopt);
const QUuid revisionBeforeReset = history.undo().value().revision;
history.begin("Open", start, std::nullopt);
history.reset();
QVERIFY(!history.canUndo() && !history.canRedo());
QCOMPARE(history.undoCount(), 0);
QVERIFY(!history.isModified());
history.begin("Three", start, std::nullopt);
history.end(documentNamed("E"), std::nullopt);
QVERIFY(history.canUndo());
const QUuid revisionAfterReset = history.undo().value().revision;
QVERIFY(!revisionAfterReset.isNull());
QVERIFY(revisionAfterReset != revisionBeforeReset);
DocumentHistory interrupted;
interrupted.begin("Open", start, std::nullopt);
interrupted.reset();
interrupted.end(documentNamed("D"), std::nullopt);
QCOMPARE(interrupted.undoCount(), 0);
QVERIFY(!interrupted.isModified());
}
void HistoryTests::historyBoundsEntriesAndUniqueRetainedPixels()
{
DocumentHistory history(2, 0);
std::optional<CanvasDocument> doc = CanvasDocument(64, 32);
doc.value().layers.push_back(ImageLayer(asset(64, 32, "fixture"), QPointF(0, 0)));
for (const QString name : {"A", "B", "C"}) {
history.begin("Rename", doc, doc.value().layers[0].id);
doc.value().layers[0].name = name;
history.end(doc, doc.value().layers[0].id);
}
QCOMPARE(history.undoCount(), 2);
QCOMPARE(history.retainedBytes(doc), 0);
history.begin("Delete", doc, doc.value().layers[0].id);
doc.value().layers.clear();
history.end(doc, std::nullopt);
QCOMPARE(history.undoCount(), 0);
QCOMPARE(history.retainedBytes(doc), 0);
}
void HistoryTests::retainedBytesCountEachHistoryOnlyImageOnce()
{
DocumentHistory history;
std::optional<CanvasDocument> doc = CanvasDocument(64, 32);
doc.value().layers.push_back(ImageLayer(asset(10, 10, "old"), QPointF(0, 0)));
for (const QString name : {"A", "B"}) {
history.begin("Rename", doc, std::nullopt);
doc.value().layers[0].name = name;
history.end(doc, std::nullopt);
}
QCOMPARE(history.retainedBytes(doc), 0);
history.begin("Replace", doc, std::nullopt);
doc.value().layers[0].asset = asset(20, 10, "new");
history.end(doc, std::nullopt);
const qint64 oldBytes = 10 * 10 * 4 + 8 * 8 * 4;
QCOMPARE(history.retainedBytes(doc), oldBytes);
QCOMPARE(history.retainedBytes(std::nullopt), oldBytes + 20 * 10 * 4 + 8 * 8 * 4);
}
void HistoryTests::byteLimitDropsTheOldestPastEntryFirst()
{
const qint64 small = 10 * 10 * 4 + 8 * 8 * 4;
DocumentHistory history(100, small);
std::optional<CanvasDocument> doc = CanvasDocument(64, 32);
doc.value().layers.push_back(ImageLayer(asset(10, 10, "x"), QPointF(0, 0)));
history.begin("First", doc, std::nullopt);
doc.value().layers[0].asset = asset(10, 10, "y");
history.end(doc, std::nullopt);
QCOMPARE(history.undoCount(), 1);
history.begin("Second", doc, std::nullopt);
doc.value().layers[0].asset = asset(10, 10, "z");
history.end(doc, std::nullopt);
QCOMPARE(history.undoCount(), 1);
QCOMPARE(history.undoName(), QString("Second"));
QCOMPARE(history.retainedBytes(doc), small);
}
void HistoryTests::undoTrimsRedoWhenItsPixelsExceedTheLimit()
{
const qint64 small = 10 * 10 * 4 + 8 * 8 * 4;
DocumentHistory history(100, small);
std::optional<CanvasDocument> doc = CanvasDocument(64, 32);
doc.value().layers.push_back(ImageLayer(asset(10, 10, "small"), QPointF(0, 0)));
const std::optional<CanvasDocument> before = doc;
history.begin("Enlarge", doc, std::nullopt);
doc.value().layers[0].asset = asset(40, 40, "large");
history.end(doc, std::nullopt);
QCOMPARE(history.undoCount(), 1);
const DocumentHistory::Snapshot undone = history.undo().value();
QVERIFY(undone.document == before);
QVERIFY(!history.canRedo());
QCOMPARE(history.retainedBytes(before), 0);
}
void HistoryTests::undoTrimsThePastBeforeTheRedoStack()
{
const qint64 small = 10 * 10 * 4 + 8 * 8 * 4;
DocumentHistory history(100, 2 * small);
std::optional<CanvasDocument> doc = CanvasDocument(64, 32);
doc.value().layers.push_back(ImageLayer(asset(10, 10, "a"), QPointF(0, 0)));
history.begin("First", doc, std::nullopt);
doc.value().layers[0].asset = asset(10, 10, "b");
history.end(doc, std::nullopt);
history.begin("Second", doc, std::nullopt);
doc.value().layers[0].asset = asset(15, 15, "c");
history.end(doc, std::nullopt);
QCOMPARE(history.undoCount(), 2);
history.undo();
QCOMPARE(history.undoCount(), 0);
QVERIFY(history.canRedo());
QCOMPARE(history.redoName(), QString("Second"));
}
void HistoryTests::negativeLimitsBecomeZero()
{
DocumentHistory history(-5, -1);
QCOMPARE(history.entryLimit, 0);
QCOMPARE(history.retainedByteLimit, 0);
history.begin("Rename", documentNamed("A"), std::nullopt);
history.end(documentNamed("B"), std::nullopt);
QCOMPARE(history.undoCount(), 0);
QVERIFY(history.isModified());
}
void HistoryTests::everyLayerEditRoundTripsWithSelection()
{
EditorSession session;
std::vector<std::pair<std::optional<CanvasDocument>, std::optional<QUuid>>> states{{std::nullopt, std::nullopt}};
const auto capture = [&] { states.push_back({session.document(), session.activeLayerID()}); };
session.createDocument(800, 600);
capture();
session.addBlankLayer();
capture();
const QUuid base = session.activeLayerID().value();
session.addBlankLayer();
capture();
const QUuid id = session.activeLayerID().value();
session.renameLayer(id, "Foreground");
capture();
session.toggleLayerVisibility(id);
capture();
session.reorderLayers({0}, 2);
capture();
session.moveActiveLayer(1);
capture();
session.deleteActiveLayer();
capture();
// Beyond Swift's list: an image, a folder, several layers.
session.insert(asset(8, 8, "Image"));
capture();
const QUuid image = session.activeLayerID().value();
session.setLayerOpacity(0.5);
capture();
session.setLayerBlendMode(LayerBlendMode::overlay);
capture();
session.beginOpacityEdit();
session.setLayerOpacity(0.25);
session.finishOpacityEdit();
capture();
session.beginTransform();
session.previewTransform({.origin = {3, 4}, .size = {16, 24}, .rotation = 10});
session.commitTransform();
capture();
session.addGroup();
capture();
const QUuid folder = session.activeLayerID().value();
session.selectLayers({image, folder}, folder);
session.groupSelectedLayers();
capture();
// Placing makes another layer active: redo must bring it back.
QVERIFY(session.placeLayer(image, std::nullopt, std::nullopt, true));
capture();
session.selectLayers({image, base}, image);
session.deleteSelectedLayers();
capture();
QCOMPARE(int(session.document().value().layers.size()), 2);
for (auto expected = states.rbegin() + 1; expected != states.rend(); ++expected) {
QVERIFY(session.canUndo());
session.undo();
QCOMPARE(session.document(), expected->first);
QCOMPARE(session.activeLayerID(), expected->second);
}
QVERIFY(!session.canUndo());
for (auto expected = states.begin() + 1; expected != states.end(); ++expected) {
session.redo();
QCOMPARE(session.document(), expected->first);
QCOMPARE(session.activeLayerID(), expected->second);
}
QVERIFY(!session.canRedo());
}
void HistoryTests::navigationNoOpsAndSaveRevisionPreserveHistory()
{
EditorSession session;
// With a view to fit, a refit undoes the zoom.
session.viewport.resize(QSizeF(400, 300), 1, std::nullopt);
session.createDocument(800, 600);
session.addBlankLayer();
const QUuid id = session.activeLayerID().value();
session.history.markSaved();
QVERIFY(!session.isModified());
session.renameLayer(id, "Changed");
QVERIFY(session.isModified());
session.undo();
QVERIFY(!session.isModified());
const int count = session.history.undoCount();
session.zoom(2);
session.renameLayer(id, "Layer 1");
session.renameLayer(id, " ");
session.reorderLayers({0}, 1);
QCOMPARE(session.history.undoCount(), count);
QVERIFY(session.canRedo());
session.redo();
QCOMPARE(session.viewport.zoom(), 2.0);
QVERIFY(session.isModified());
session.undo();
session.addBlankLayer();
QVERIFY(!session.canRedo());
QVERIFY(session.isModified());
}
void HistoryTests::replacementCanvasAndNestedTransactionsUndoAsOne()
{
EditorSession session;
session.createDocument(100, 200);
session.beginEdit("Layer Setup");
session.addBlankLayer();
session.addBlankLayer();
QVERIFY(!session.canUndo());
session.endEdit();
QCOMPARE(session.history.undoName(), QString("Layer Setup"));
session.undo();
QVERIFY(session.document().value().layers.empty());
session.redo();
const std::optional<CanvasDocument> previous = session.document();
session.viewport.resize(QSizeF(800, 600), 1, previous.value().size());
session.zoom(3);
session.createDocument(300, 400);
QCOMPARE(session.history.undoName(), QString("New Canvas"));
session.zoom(3);
session.undo();
QCOMPARE(session.document(), previous);
// Another canvas came back: the view fits it again.
QVERIFY(session.viewport.followsFit());
session.redo();
QCOMPARE(session.document().value().size(), QSizeF(300, 400));
}
void HistoryTests::historyBlockedDuringImportsAndDialogs()
{
EditorSession session;
session.createDocument(40, 40);
session.setIsImporting(true);
session.undo();
QVERIFY(session.document().has_value());
session.setIsImporting(false);
session.setShowsNewDocument(true);
session.undo();
QVERIFY(session.document().has_value());
session.setShowsNewDocument(false);
session.undo();
session.setShowsImporter(true);
session.redo();
QVERIFY(!session.document().has_value());
session.setShowsImporter(false);
session.redo();
QVERIFY(session.document().has_value());
// A busy project, a rename, an import error block too.
const auto blocked = [&](const std::function<void(bool)> &set) {
set(true);
const bool refused = !session.canUndo() && !session.canUseHistory();
set(false);
return refused && session.canUndo();
};
QVERIFY(blocked([&](bool on) { session.setIsProjectBusy(on); }));
QVERIFY(blocked([&](bool on) { session.setRenamingLayerID(on ? std::optional(QUuid::createUuid()) : std::nullopt); }));
QVERIFY(blocked([&](bool on) { session.setImportError(on ? std::optional(QString("failed")) : std::nullopt); }));
}
QTEST_GUILESS_MAIN(HistoryTests)
#include "HistoryTests.moc"