12.2a: folders duplicate with their contents, folder opacity, format version 8, View > Snap (Compositor 1.1.5–1.1.6)

This commit is contained in:
雷电芽衣 committed 2026-09-26 18:17:14 -04:00
1 parent ee34c338d4
commit f1b2c65c1e
52 files changed
+909 -239

No files matched your search

+2 -41
View File
@@ -15,8 +15,6 @@ private slots:
void fileEntriesFollowTheControllersGate();
void undoAndRedoCarryTheirNamesAndKeepAFieldsOwn();
void theMenuBarBorrowsFocusAndTheFieldKeepsUndo();
void viewEntriesZoomTheFrontSession();
void thePixelGridEntryTogglesTheSession();
void layerEntriesFollowTheActiveLayer();
void duplicateMergeAndFlipEntriesActOnTheLayers();
void transformEntriesFollowTheMoveTool();
@@ -32,7 +30,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
{"newCanvas", "Ctrl+N"}, {"openProject", "Ctrl+O"}, {"importImages", ""}, {"save", "Ctrl+S"}, {"saveAs", "Ctrl+Shift+S"},
{"exportPNG", "Ctrl+Shift+E"}, {"exportJPEG", "Ctrl+Alt+Shift+S"},
{"closeProject", "Ctrl+W"}, {"undo", "Ctrl+Z"}, {"redo", "Ctrl+Shift+Z"}, {"fit", "Ctrl+0"}, {"actualPixels", "Ctrl+1"},
{"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"},
{"zoomIn", "Ctrl+="}, {"zoomOut", "Ctrl+-"}, {"pixelGrid", ""}, {"snap", ""}, {"transformControls", "Ctrl+H"}, {"transformLayer", "Ctrl+T"},
{"layerViaCopy", "Ctrl+J"}, {"cut", "Ctrl+X"}, {"copy", "Ctrl+C"}, {"copyMerged", "Ctrl+Shift+C"}, {"paste", "Ctrl+V"}, {"fillForeground", "Alt+Backspace"}, {"fillBackground", "Ctrl+Backspace"}, {"clearSelectionPixels", ""}, {"contentAwareFill", "Shift+Backspace"}, {"clippingMask", "Ctrl+Alt+G"}, {"groupLayers", "Ctrl+G"},
{"moveOutOfFolder", ""}, {"newBlankLayer", "Ctrl+Shift+N"}, {"renameLayer", ""}, {"layerVisibility", ""}, {"moveLayerUp", "Ctrl+]"}, {"moveLayerDown", "Ctrl+["},
{"mergeLayers", "Ctrl+E"}, {"flipHorizontal", ""}, {"flipVertical", ""}, {"deleteLayer", ""},
@@ -53,7 +51,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand()
QCOMPARE(titles, (QStringList{"&File", "&Edit", "&View", "&Select", "&Image", "Fil&ter", "&Layer"}));
QCOMPARE(menus[0]->actions().size(), 11);
QCOMPARE(menus[1]->actions().size(), 12);
QCOMPARE(menus[2]->actions().size(), 6);
QCOMPARE(menus[2]->actions().size(), 7);
QCOMPARE(menus[3]->actions().size(), 10);
QCOMPARE(menus[4]->actions().size(), 13);
QCOMPARE(menus[5]->actions().size(), 5);
@@ -226,43 +224,6 @@ void CompositorMenusTests::theMenuBarBorrowsFocusAndTheFieldKeepsUndo()
QTRY_COMPARE(undo.text(), QString("Undo New Blank Layer"));
}
void CompositorMenusTests::viewEntriesZoomTheFrontSession()
{
Bar bar;
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
QVERIFY(!bar.action(name).isEnabled());
bar.session().createDocument(400, 300);
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
QVERIFY(bar.action(name).isEnabled());
bar.session().zoom(0.5);
bar.action("actualPixels").trigger();
QCOMPARE(bar.session().viewport.zoom(), 1.0);
bar.action("zoomIn").trigger();
QCOMPARE(bar.session().viewport.zoom(), 1.25);
bar.action("zoomOut").trigger();
bar.action("zoomOut").trigger();
QCOMPARE(bar.session().viewport.zoom(), 0.8);
bar.action("fit").trigger();
QVERIFY(bar.session().viewport.followsFit());
}
void CompositorMenusTests::thePixelGridEntryTogglesTheSession()
{
Bar bar;
QAction &grid = bar.action("pixelGrid");
QVERIFY(grid.isCheckable() && grid.isChecked() && grid.isEnabled());
QCOMPARE(grid.text(), QString("Pixel Grid (800% and above)"));
grid.trigger();
QVERIFY(!bar.session().showsPixelGrid() && !grid.isChecked());
grid.trigger();
QVERIFY(bar.session().showsPixelGrid() && grid.isChecked());
// The session's own change reaches the mark.
bar.session().setShowsPixelGrid(false);
QVERIFY(!grid.isChecked());
bar.workspace.newCanvas();
QVERIFY(grid.isChecked() && grid.isEnabled());
}
void CompositorMenusTests::layerEntriesFollowTheActiveLayer()
{
Bar bar;
+5
View File
@@ -199,6 +199,11 @@ void CropCanvasTests::snappingReachesEightViewPoints()
const QPointF from = shown.session.viewport.viewPoint(QPointF(100, 50), size), to = shown.session.viewport.viewPoint(QPointF(390, 170), size);
shown.drag(from, to);
QCOMPARE(shown.frame(), std::optional(QRectF(100, 50, 300, 120)));
// Snap off: the edges stay where the pointer puts them.
shown.session.setSnappingEnabled(false);
shown.session.setCropRect(std::nullopt);
shown.drag(from, to);
QCOMPARE(shown.frame(), std::optional(QRectF(100, 50, 290, 120)));
}
void CropCanvasTests::theCursorShowsWhatAPressTakes()
-46
View File
@@ -43,7 +43,6 @@ private slots:
void foldingAFolderTakesTheSelectionFromItsContents();
void deletingAFolderTakesItsContents();
void movingStaysAmongSiblings();
void duplicatingALayerByDraggingPlacesTheCopyAsOneStep();
void aNewCanvasCanStartWithALayer();
void sessionChangesAreLogged();
};
@@ -445,50 +444,5 @@ void EditorSessionTests::sessionChangesAreLogged()
}
// Alt-dragging a row drops a duplicate, one step.
void EditorSessionTests::duplicatingALayerByDraggingPlacesTheCopyAsOneStep()
{
EditorSession session;
session.createDocument(800, 600);
for (int each = 0; each < 3; ++each)
session.addBlankLayer();
const std::vector<LayerHierarchy::Entry> rows = session.layerRows();
const ProjectLayerRecord top = rows.front().layer, bottom = rows.back().layer;
const std::optional<CanvasDocument> before = session.document();
const int count = session.history.undoCount();
QVERIFY(session.duplicateLayer(bottom.id, std::nullopt, top.id));
const std::vector<LayerHierarchy::Entry> after = session.layerRows();
QCOMPARE(after.size(), size_t(4));
QCOMPARE(session.history.undoCount(), count + 1);
QCOMPARE(session.history.undoName(), QString("Duplicate Layer"));
QCOMPARE(after.front().layer.name, bottom.name + " copy");
QCOMPARE(after.front().layer.id, session.activeLayerID());
QVERIFY(indexOf(session.document().value().layers, bottom.id) >= 0);
session.undo();
QCOMPARE(session.document(), before);
// Folders are not duplicated this way; neither is a stranger.
session.addGroup();
const QUuid folder = session.activeLayerID().value();
QVERIFY(!session.duplicateLayer(folder, std::nullopt, std::nullopt, true));
QVERIFY(!session.duplicateLayer(QUuid::createUuid(), std::nullopt));
QVERIFY(!session.duplicateLayer(bottom.id, bottom.id));
QCOMPARE(session.layerRows().size(), size_t(4));
// Into a folder, at its top.
QVERIFY(session.duplicateLayer(bottom.id, folder));
QCOMPARE(layerWith(session, session.activeLayerID().value()).parentID, std::optional(folder));
session.setIsImporting(true);
QVERIFY(!session.duplicateLayer(bottom.id, std::nullopt));
session.setIsImporting(false);
// The copy sits right above; a folder makes none.
session.selectLayer(top.id);
session.duplicateActiveLayer();
const int original = indexOf(session.document().value().layers, top.id);
QCOMPARE(session.document().value().layers[original + 1].name, top.name + " copy");
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[original + 1].id));
const size_t kept = session.document().value().layers.size();
session.selectLayer(folder);
session.duplicateActiveLayer();
QCOMPARE(session.document().value().layers.size(), kept);
}
QTEST_GUILESS_MAIN(EditorSessionTests)
#include "EditorSessionTests.moc"
+24 -10
View File
@@ -147,7 +147,8 @@ void EffectsPanelTests::theSheetEditsItsEffect()
QCOMPARE(size.text(), QString("4"));
type(size, "12");
QCOMPARE(editor.effects().stroke->size, 12.0);
QCOMPARE(slider.value(), 22);
// The slider spans 0 to 20; typing reaches 500.
QCOMPARE(slider.value(), 600);
// No finite number changes nothing; the field goes back.
type(size, "abc");
QVERIFY(editor.effects().stroke->size == 12 && size.text() == "12");
@@ -158,6 +159,8 @@ void EffectsPanelTests::theSheetEditsItsEffect()
QCOMPARE(editor.effects().stroke->size, 13.0);
QTest::keyClick(&size, Qt::Key_Up, Qt::ShiftModifier);
QCOMPARE(editor.effects().stroke->size, 23.0);
// Past its end the thumb rests there, writing nothing back.
QCOMPARE(slider.value(), 1000);
// A step replaces pending typing, even where it clamps.
typing(size, "12");
QTest::keyClick(&size, Qt::Key_Up);
@@ -172,12 +175,12 @@ void EffectsPanelTests::theSheetEditsItsEffect()
size.clearFocus();
QCOMPARE(editor.effects().stroke->size, 500.0);
// The slider sets any value; the field rounds, ties even.
slider.setValue(500);
QVERIFY(editor.effects().stroke->size == 250.5 && size.text() == "250");
slider.setValue(525);
QVERIFY(editor.effects().stroke->size == 10.5 && size.text() == "10");
// Leaving an untouched field keeps the exact value.
size.setFocus();
size.clearFocus();
QCOMPARE(editor.effects().stroke->size, 250.5);
QCOMPARE(editor.effects().stroke->size, 10.5);
// Typing waits while other changes arrive.
auto &opacity = find<QLineEdit>(sheet, "opacityField");
QCOMPARE(opacity.text(), QString("100"));
@@ -206,6 +209,11 @@ void EffectsPanelTests::theSheetEditsItsEffect()
editor.session.setColorPickerHSB(PickerHSB(PaletteColor{1, 0, 0}));
QCOMPARE(editor.effects().stroke->color(), (PaletteColor{1, 0, 0}));
QTRY_VERIFY(paints > 0);
// A black rim, a white ring inside, then the colour.
const QImage shot = swatch.grab().toImage();
QVERIFY2(shot.pixelColor(0, 9) == QColor(Qt::black), qPrintable(shot.pixelColor(0, 9).name()));
QVERIFY2(shot.pixelColor(1, 9) == QColor(Qt::white), qPrintable(shot.pixelColor(1, 9).name()));
QCOMPARE(shot.pixelColor(18, 9), QColor(Qt::red));
editor.session.closeColorPicker(true);
// Return commits, then goes on to OK, keeping everything.
typing(size, "7");
@@ -227,7 +235,7 @@ void EffectsPanelTests::aWheelOnTheSliderReplacesTheTyping()
QWheelEvent wheel(QPointF(10, 5), slider.mapToGlobal(QPointF(10, 5)), QPoint(), QPoint(0, -120), Qt::NoButton, Qt::NoModifier,
Qt::NoScrollPhase, false);
QApplication::sendEvent(&slider, &wheel);
const double wheeled = 1 + 499.0 * (6 - QApplication::wheelScrollLines()) / 1000;
const double wheeled = 20.0 * (200 - QApplication::wheelScrollLines()) / 1000;
QCOMPARE(editor.effects().stroke->size, wheeled);
QVERIFY(size.hasFocus() && size.text() == QString::number(std::lround(wheeled)) && !size.isModified());
// Return then keeps the slider's value, going on to OK.
@@ -276,11 +284,13 @@ void EffectsPanelTests::eachKindHasSwiftsControls_data()
QTest::addColumn<LayerEffectKind>("kind");
QTest::addColumn<QString>("headline");
QTest::addColumn<QStringList>("fields");
const QStringList shadow{"Opacity:0:100", "Angle:-180:180", "Distance:0:300", "Blur:0:300"};
QTest::newRow("stroke") << LayerEffectKind::stroke << QString("Stroke") << QStringList{"Size:1:500", "Opacity:0:100"};
// Title, typed floor and ceiling, then the slider's.
const QStringList shadow{"Opacity:0:100:100", "Angle:-180:180:180", "Distance:0:5000:100", "Blur:0:500:100"};
QTest::newRow("stroke") << LayerEffectKind::stroke << QString("Stroke") << QStringList{"Size:0:500:20", "Opacity:0:100:100"};
QTest::newRow("shadow") << LayerEffectKind::shadow << QString("Drop Shadow") << shadow;
QTest::newRow("overlay") << LayerEffectKind::colorOverlay << QString("Color Overlay") << QStringList{"Opacity:0:100"};
QTest::newRow("inner shadow") << LayerEffectKind::innerShadow << QString("Inner Shadow") << shadow;
QTest::newRow("overlay") << LayerEffectKind::colorOverlay << QString("Color Overlay") << QStringList{"Opacity:0:100:100"};
QTest::newRow("inner shadow") << LayerEffectKind::innerShadow << QString("Inner Shadow")
<< QStringList{"Opacity:0:100:100", "Angle:-180:180:180", "Distance:0:5000:50", "Blur:0:500:100"};
}
void EffectsPanelTests::eachKindHasSwiftsControls()
@@ -312,6 +322,10 @@ void EffectsPanelTests::eachKindHasSwiftsControls()
QCOMPARE(field.text(), parts[2]);
type(field, "-99999");
QCOMPARE(field.text(), parts[1]);
// The slider's end sets its range's top.
auto &slider = *sheet.findChild<QSlider *>(parts[0].toLower() + "Slider");
slider.setValue(1000);
QCOMPARE(field.text(), parts[3]);
}
QCOMPARE(names, titles);
}
@@ -342,7 +356,7 @@ void EffectsPanelTests::cancelAndEscapePutTheEffectBack()
editor.session.selectEffect(LayerEffectKind::shadow, editor.layer, true);
QCOMPARE(find<QLineEdit>(*editor.sheet(), "distanceField").text(), QString("20"));
find<QSlider>(*editor.sheet(), "distanceSlider").setValue(400);
QCOMPARE(editor.effects().shadow->distance, 120.0);
QCOMPARE(editor.effects().shadow->distance, 40.0);
find<QPushButton>(*editor.sheet(), "effectsCancel").click();
QVERIFY(editor.effects().shadow->distance == 20 && !editor.session.effectsEditing());
// A new overlay's panel: Escape takes the overlay away again.
+300
View File
@@ -0,0 +1,300 @@
#include "Document/LiveLayerMask.h"
#include "Rendering/EditorCanvas.h"
#include "IO/ImageExporter.h"
#include "RenderFixtures.h"
#include "SessionFixtures.h"
#include <QTemporaryDir>
#include <QtTest>
// A folder's opacity dims each layer inside it (Swift 1.1.6).
namespace {
ImportedImage filled(int width, int height, QRgb premultiplied, const QString &name)
{
const QImage image = solid(width, height, premultiplied);
return ImportedImage(image, image, name);
}
// The layer wrapped in a folder, the folder left active.
QUuid wrap(EditorSession &session, QUuid id)
{
session.selectLayers({id}, id);
session.groupSelectedLayers();
return session.activeLayerID().value();
}
void dim(EditorSession &session, QUuid id, double opacity)
{
session.selectLayer(id);
session.setLayerOpacity(opacity);
}
QImage exported(const EditorSession &session)
{
return ImageExporter::render(session.projectSnapshot().value()).image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
}
int alpha(const QImage &image, int x, int y)
{
return image.constScanLine(y)[x * 4 + 3];
}
std::optional<std::pair<double, std::optional<QUuid>>> none(QUuid)
{
return std::nullopt;
}
}
class FolderOpacityTests : public QObject {
Q_OBJECT
private slots:
void opacitiesMultiplyUpTheTree();
void theExportAndTheCompositeDimInsideAFolder();
void anAdjustmentDimsWithItsFolder();
void effectsDimWithTheirFolder();
void theCanvasShowsAndFollowsAFoldersOpacity();
void theCanvasDimsEveryWayALayerDraws_data();
void theCanvasDimsEveryWayALayerDraws();
void aClippingSourceBakesDimmed();
void aDimmedFolderSavesAndReopens();
};
void FolderOpacityTests::opacitiesMultiplyUpTheTree()
{
const QUuid inner = QUuid::createUuid(), outer = QUuid::createUuid();
const auto tree = [&](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
if (id == inner)
return std::pair(0.5, std::optional(outer));
if (id == outer)
return std::pair(0.8, std::optional<QUuid>());
return std::nullopt;
};
QCOMPARE(LayerOpacity::effective(0.5, inner, tree), 0.2);
QCOMPARE(LayerOpacity::effective(0.5, std::nullopt, tree), 0.5);
// A parent nobody knows ends the walk.
QCOMPARE(LayerOpacity::effective(0.5, QUuid::createUuid(), none), 0.5);
// A loop stops after 64 folders, as Swift's does.
const QUuid loop = QUuid::createUuid();
const auto ring = [&](QUuid) -> std::optional<std::pair<double, std::optional<QUuid>>> { return std::pair(0.9, std::optional(loop)); };
QCOMPARE(LayerOpacity::effective(1, loop, ring), std::pow(0.9, 64));
// A document that repeats an id is a bug.
CanvasDocument twice(4, 4);
twice.layers = {ImageLayer(QStringLiteral("One"), QSizeF(4, 4))};
twice.layers.push_back(twice.layers.front());
QVERIFY_THROWS_EXCEPTION(std::logic_error, twice.effectiveOpacities());
// The document's map and each record's agree.
EditorSession session;
session.createDocument(4, 4);
session.insert(filled(4, 4, qRgba(255, 0, 0, 255), "Red"));
const QUuid red = session.activeLayerID().value();
dim(session, red, 0.5);
const QUuid folder = wrap(session, red);
const QUuid top = wrap(session, folder);
dim(session, folder, 0.5);
dim(session, top, 0.8);
const QHash<QUuid, double> opacities = session.document().value().effectiveOpacities();
QCOMPARE(opacities.value(red), 0.2);
QCOMPARE(opacities.value(folder), 0.4);
QCOMPARE(opacities.value(top), 0.8);
std::map<QUuid, ProjectLayerRecord> records;
const ProjectSnapshot snapshot = session.projectSnapshot().value();
for (const ProjectLayerRecord &record : snapshot.manifest.layers)
records.emplace(record.id, record);
QCOMPARE(records.at(red).effectiveOpacity(records), 0.2);
// The layer itself still reads its own opacity.
QCOMPARE(layerWith(session, red).opacity, 0.5);
}
void FolderOpacityTests::theExportAndTheCompositeDimInsideAFolder()
{
EditorSession session;
session.createDocument(4, 4);
session.insert(filled(4, 4, qRgba(255, 0, 0, 255), "Red"));
const QUuid red = session.activeLayerID().value();
const QUuid folder = wrap(session, red);
QCOMPARE(alpha(exported(session), 1, 1), 255);
dim(session, folder, 0.5);
QCOMPARE(alpha(exported(session), 1, 1), 127);
// Nested, each folder multiplies in.
const QUuid top = wrap(session, folder);
dim(session, top, 0.5);
QCOMPARE(alpha(exported(session), 1, 1), 63);
QImage composite = BrushRaster::context(4, 4, false);
QPainter painter(&composite);
session.drawLiveComposite(session.document().value(), painter);
painter.end();
QCOMPARE(alpha(composite, 1, 1), 63);
}
void FolderOpacityTests::anAdjustmentDimsWithItsFolder()
{
// Half an adjustment equals a whole one, half folded.
const auto scene = [](bool folded) {
auto session = std::make_unique<EditorSession>();
session->createDocument(4, 4);
session->insert(filled(4, 4, qRgba(255, 0, 0, 255), "Red"));
session->addAdjustment(AdjustmentKind::gradientMap);
session->setAdjustmentEditingID(std::nullopt);
const QUuid adjustment = session->activeLayerID().value();
if (folded)
dim(*session, wrap(*session, adjustment), 0.5);
else
dim(*session, adjustment, 0.5);
QImage composite = BrushRaster::context(4, 4, false);
QPainter painter(&composite);
session->drawLiveComposite(session->document().value(), painter);
painter.end();
return std::pair(exported(*session), composite);
};
const auto half = scene(false), folded = scene(true);
QCOMPARE(folded.first, half.first);
QCOMPARE(folded.second, half.second);
QVERIFY(half.first.pixelColor(1, 1) != QColor(Qt::red) && half.second.pixelColor(1, 1) != QColor(Qt::red));
}
void FolderOpacityTests::effectsDimWithTheirFolder()
{
// A stroked layer: half alone, or whole, half folded.
const auto scene = [](bool folded) {
auto session = std::make_unique<EditorSession>();
session->createDocument(20, 20);
session->insert(filled(10, 10, qRgba(255, 0, 0, 255), "Red"));
const QUuid red = session->activeLayerID().value();
LayerEffects effects;
effects.stroke = StrokeEffect();
session->setEffects(effects, red);
if (folded)
dim(*session, wrap(*session, red), 0.5);
else
dim(*session, red, 0.5);
QImage composite = BrushRaster::context(20, 20, false);
QPainter painter(&composite);
session->drawLiveComposite(session->document().value(), painter);
painter.end();
return std::pair(exported(*session), composite);
};
const auto half = scene(false), folded = scene(true);
QCOMPARE(folded.first, half.first);
QCOMPARE(folded.second, half.second);
QVERIFY(alpha(half.first, 10, 10) == 127 && alpha(half.first, 4, 10) > 0);
}
void FolderOpacityTests::theCanvasShowsAndFollowsAFoldersOpacity()
{
EditorSession session;
CanvasView canvas(session);
session.createDocument(20, 20);
session.setShowsTransformControls(false);
canvas.resize(40, 40);
session.viewport.resize(QSizeF(40, 40), 1, QSizeF(20, 20));
session.zoom(1);
// White below, so canvas and export both show opaque.
session.insert(filled(20, 20, qRgba(255, 255, 255, 255), "White"));
session.insert(filled(20, 20, qRgba(255, 0, 0, 255), "Red"));
const QUuid folder = wrap(session, session.activeLayerID().value());
canvas.synchronizeDisplay();
const QPoint middle = session.viewport.viewPoint(QPointF(10, 10), QSizeF(20, 20)).toPoint();
QCOMPARE(canvas.grab().toImage().pixelColor(middle), QColor(Qt::red));
// A folder holds no pixels, yet its opacity redraws.
dim(session, folder, 0.5);
QVERIFY(canvas.synchronizeDisplay());
const QColor shown = canvas.grab().toImage().pixelColor(middle);
const QColor expected = exported(session).pixelColor(10, 10);
QVERIFY2(std::abs(shown.green() - expected.green()) <= 1 && std::abs(expected.green() - 128) <= 1 && shown.red() == 255,
qPrintable(shown.name() + expected.name()));
}
void FolderOpacityTests::aClippingSourceBakesDimmed()
{
EditorSession session;
session.createDocument(4, 4);
session.insert(filled(4, 4, qRgba(255, 0, 0, 255), "Base"));
const QUuid base = session.activeLayerID().value();
session.insert(filled(4, 4, qRgba(0, 255, 0, 255), "Clipped"));
const QUuid clipped = session.activeLayerID().value();
session.toggleClippingMask(clipped);
session.selectLayers({base, clipped}, clipped);
session.groupSelectedLayers();
const QUuid folder = session.activeLayerID().value();
const QImage whole = LiveMaskBaker::bake(session.projectSnapshot().value(), clipped).value().image();
QCOMPARE(qAlpha(whole.pixel(1, 1)), 255);
dim(session, folder, 0.5);
const QImage dimmed = LiveMaskBaker::bake(session.projectSnapshot().value(), clipped).value().image();
QCOMPARE(qAlpha(dimmed.pixel(1, 1)), 127);
// A folder around the folder dims the source again.
dim(session, wrap(session, folder), 0.5);
const QImage twice = LiveMaskBaker::bake(session.projectSnapshot().value(), clipped).value().image();
QCOMPARE(qAlpha(twice.pixel(1, 1)), 63);
}
void FolderOpacityTests::aDimmedFolderSavesAndReopens()
{
QTemporaryDir root;
EditorSession session;
session.createDocument(4, 4);
session.insert(filled(4, 4, qRgba(255, 0, 0, 255), "Red"));
const QUuid folder = wrap(session, session.activeLayerID().value());
dim(session, folder, 0.5);
QCOMPARE(layerWith(session, folder).opacity, 0.5);
const QString path = root.filePath("Dimmed.comp");
ProjectStore::save(session.projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(loaded.manifest.version, qint64(8));
for (const ProjectLayerRecord &record : loaded.manifest.layers) {
if (record.isGroup == true)
QCOMPARE(record.opacity, std::optional(0.5));
}
}
void FolderOpacityTests::theCanvasDimsEveryWayALayerDraws_data()
{
QTest::addColumn<QString>("way");
QTest::newRow("adjustment") << "adjustment";
QTest::newRow("effects") << "effects";
QTest::newRow("warp") << "warp";
}
void FolderOpacityTests::theCanvasDimsEveryWayALayerDraws()
{
QFETCH(QString, way);
// The canvas, half alone against whole in a half folder.
const auto scene = [&way](bool folded) {
EditorSession session;
CanvasView canvas(session);
session.createDocument(20, 20);
session.setShowsTransformControls(false);
canvas.resize(40, 40);
session.viewport.resize(QSizeF(40, 40), 1, QSizeF(20, 20));
session.zoom(1);
session.insert(filled(20, 20, qRgba(255, 255, 255, 255), "White"));
session.insert(filled(10, 10, qRgba(255, 0, 0, 255), "Red"));
QUuid target = session.activeLayerID().value();
if (way == "adjustment") {
session.addAdjustment(AdjustmentKind::gradientMap);
session.setAdjustmentEditingID(std::nullopt);
target = session.activeLayerID().value();
} else if (way == "effects") {
LayerEffects effects;
effects.stroke = StrokeEffect();
session.setEffects(effects, target);
}
dim(session, folded ? wrap(session, target) : target, 0.5);
if (way == "warp") {
session.selectLayer(target);
session.selectTool(NavigationTool::blur);
session.beginBrush(QPointF(10, 10));
if (!session.warpStroke())
throw std::runtime_error("no warp began");
}
canvas.synchronizeDisplay();
if (way == "effects" && !QTest::qWaitFor([&] { canvas.grab(); return session.effectsPreviews.rendered(target).has_value(); }))
throw std::runtime_error("the effects never rendered");
return canvas.grab().toImage();
};
const QImage half = scene(false), folded = scene(true);
QCOMPARE(folded, half);
const QPoint inside(20, 20);
QVERIFY2(half.pixelColor(inside) != QColor(Qt::red) && half.pixelColor(inside) != QColor(Qt::white), qPrintable(half.pixelColor(inside).name()));
}
QTEST_MAIN(FolderOpacityTests)
#include "FolderOpacityTests.moc"
+1 -1
View File
@@ -217,7 +217,7 @@ void GroupSessionTests::groupsRoundTripAndSurviveImageAndCanvasResize()
session.addBlankLayer();
const QUuid child = session.activeLayerID().value();
const ProjectSnapshot snapshot = session.projectSnapshot().value();
QCOMPARE(snapshot.manifest.version, qint64(7));
QCOMPARE(snapshot.manifest.version, qint64(8));
const auto record = [](const ProjectSnapshot &from, QUuid id) {
for (const ProjectLayerRecord &layer : from.manifest.layers) {
if (layer.id == id)
+1 -1
View File
@@ -261,7 +261,7 @@ void GroupTests::projectErrorsCarryTheirMessages()
const ProjectError version = ProjectError::unsupportedVersion(9);
QCOMPARE(version.kind, ProjectError::Kind::version);
QCOMPARE(version.version, std::optional<int>(9));
QCOMPARE(QString(version.what()), QString("This project uses format version 9. This app supports versions 1–7."));
QCOMPARE(QString(version.what()), QString("This project uses format version 9. This app supports versions 1–8."));
QVERIFY_THROWS_EXCEPTION(std::logic_error, ProjectError(ProjectError::Kind::version));
const ProjectError missingImage(ProjectError::Kind::missingImage);
+16
View File
@@ -1,5 +1,6 @@
#include "UI/LayerAppearanceControls.h"
#include "UI/BlendModePicker.h"
#include <QLabel>
#include <QtTest>
// The opacity slider and field, one undo step a drag.
@@ -52,6 +53,21 @@ void LayerAppearanceControlsTests::theControlsFollowTheActiveLayerAndItsGate()
QVERIFY(controls.widget.findChild<BlendModePicker *>() != nullptr);
controls.session.selectLayers({controls.session.document().value().layers.front().id, controls.session.document().value().layers.back().id}, std::nullopt);
QVERIFY(!controls.widget.isEnabled());
// A folder: its opacity edits, its blend mode rests.
controls.session.addGroup();
QVERIFY(controls.widget.isEnabled() && controls.slider.isEnabled() && controls.field.isEnabled());
const auto blendRow = [&] {
for (QLabel *label : controls.widget.findChildren<QLabel *>()) {
if (label->text() == "Blend")
return label;
}
throw std::runtime_error("no Blend caption");
};
QVERIFY(!controls.widget.findChild<BlendModePicker *>()->isEnabled() && !blendRow()->isEnabled());
controls.slider.setValue(500);
QCOMPARE(controls.opacity(), 0.5);
controls.session.selectLayer(controls.session.document().value().layers.front().id);
QVERIFY(controls.widget.findChild<BlendModePicker *>()->isEnabled() && blendRow()->isEnabled());
}
void LayerAppearanceControlsTests::aSliderDragIsOneUndoStep()
+21 -7
View File
@@ -119,7 +119,7 @@ void LayerAppearanceTests::appearancePersistsThroughSaveResizeAndTransparentExpo
const QString path = folder.filePath("Appearance.comp");
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(loaded.manifest.version, qint64(7));
QCOMPARE(loaded.manifest.version, qint64(8));
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 8, .height = 8, .resolution = 72});
const ProjectSnapshot canvas = CanvasResizer::resize(resized, {.width = 12, .height = 12});
const auto record = std::find_if(canvas.manifest.layers.begin(), canvas.manifest.layers.end(),
@@ -253,7 +253,8 @@ void LayerAppearanceTests::selectedLayersTakeOneOpacityInOneStep()
session.setSelectedLayersOpacity(0.5);
QCOMPARE(layerWith(session, first).opacity, 0.5);
QCOMPARE(layerWith(session, second).opacity, 0.5);
QCOMPARE(layerWith(session, folder).opacity, 1.0);
// A selected folder takes it too.
QCOMPARE(layerWith(session, folder).opacity, 0.5);
QCOMPARE(layerWith(session, apart).opacity, 1.0);
QCOMPARE(session.history.undoCount(), count + 1);
QCOMPARE(session.history.undoName(), QString("Layer Opacity"));
@@ -326,13 +327,27 @@ void LayerAppearanceTests::onlyOnePixelLayerHasAnAppearanceToEdit()
QVERIFY(session.canEditAppearance());
session.addGroup();
const QUuid folder = session.activeLayerID().value();
// A folder takes an opacity; blending stays per layer.
QVERIFY(!session.canEditAppearance());
QVERIFY(session.canEditOpacity());
session.setLayerBlendMode(LayerBlendMode::screen);
session.setLayerOpacity(0.5);
session.previewBlendMode(LayerBlendMode::screen, folder);
QVERIFY(!session.blendPreview().has_value());
QCOMPARE(layerWith(session, folder).blendMode, LayerBlendMode::normal);
QCOMPARE(layerWith(session, folder).opacity, 1.0);
session.setLayerOpacity(0.5);
QCOMPARE(layerWith(session, folder).opacity, 0.5);
QCOMPARE(session.history.undoName(), QString("Layer Opacity"));
session.beginOpacityEdit();
session.setLayerOpacity(0.25);
session.setLayerOpacity(0.2);
session.finishOpacityEdit();
QCOMPARE(layerWith(session, folder).opacity, 0.2);
session.undo();
QCOMPARE(layerWith(session, folder).opacity, 0.5);
// Two layers selected: neither gate holds.
session.selectLayers({folder, session.document().value().layers.front().id}, folder);
QVERIFY(!session.canEditOpacity());
session.selectLayer(folder);
session.addBlankLayer();
QVERIFY(session.canEditAppearance());
session.setShowsImporter(true);
@@ -441,10 +456,9 @@ void LayerAppearanceTests::moveToolNumberKeysSetSelectedLayersOpacityAsOneUndo()
const int count = session.history.undoCount();
session.typeOpacityDigit(5, 10);
for (const ImageLayer &layer : session.document().value().layers) {
if (layer.id == first || layer.id == second)
// The folder takes it too, since 1.1.6.
if (layer.id == first || layer.id == second || layer.id == folder)
QCOMPARE(layer.opacity, 0.5);
if (layer.id == folder)
QCOMPARE(layer.opacity, 1.0);
}
QCOMPARE(session.history.undoCount(), count + 1);
}
+4 -2
View File
@@ -65,13 +65,15 @@ void LayerDragGestureTests::altShowsTheClippingAndDuplicateCursors()
QVERIFY(image(CanvasView::loadSelectionCursor(1)).pixelColor(11, 8).lightness() > 128);
QVERIFY(image(CanvasView::loadSelectionCursor(1)).pixelColor(21, 18).alpha() > 0);
QCOMPARE(image(CanvasView::loadSelectionCursor(1)).pixelColor(21, 21).alpha(), 0);
// The bottom row clips to nothing; a folder duplicates not.
// The bottom row clips nothing; a folder has no strip.
const QPoint lastStrip = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, LayerCell::rowHeight - 4));
QCOMPARE(shown.list.cursorFor(lastStrip, Qt::AltModifier).shape(), Qt::ArrowCursor);
session->selectLayer(shown.id(1));
session->addGroup();
const QPoint folderBody = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, 20));
QCOMPARE(shown.list.cursorFor(folderBody, Qt::AltModifier).shape(), Qt::ArrowCursor);
QCOMPARE(image(shown.list.cursorFor(folderBody, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio)));
const QPoint folderStrip = shown.list.cells().at(1)->mapTo(&shown.list, QPoint(200, LayerCell::rowHeight - 4));
QCOMPARE(image(shown.list.cursorFor(folderStrip, Qt::AltModifier)), image(CanvasView::duplicateCursor(ratio)));
// A mask thumbnail copies, or loads with Ctrl; busy, nothing.
session->selectLayer(shown.id(0));
session->addLayerMask();
+17 -3
View File
@@ -125,11 +125,25 @@ void LayerDragTests::anAltDragDropsDuplicates()
QCOMPARE(session->history.undoName(), QString("Duplicate Layers"));
session->undo();
QCOMPARE(shown.names().size(), size_t(3));
// A folder among the dragged rows: no copy at all.
session->selectLayer(shown.id(2));
// A folder among the dragged rows copies with its contents.
const QUuid held = shown.id(2);
session->selectLayer(held);
session->addGroup();
QVERIFY(!shown.drop(shown.rows({0, 2}), Qt::CopyAction, shown.below()));
QVERIFY(session->placeLayer(held, session->activeLayerID().value()));
QCOMPARE(shown.names().size(), size_t(4));
QVERIFY(session->document().value().layers[size_t(indexOf(session->document().value().layers, shown.id(2)))].isGroup);
QVERIFY(shown.drop(shown.rows({0, 2}), Qt::CopyAction, shown.below()));
QCOMPARE(session->history.undoName(), QString("Duplicate Layers"));
const std::vector<ImageLayer> &layers = session->document().value().layers;
QCOMPARE(int(layers.size()), 7);
std::vector<QUuid> folders;
for (const ImageLayer &layer : layers) {
if (layer.isGroup)
folders.push_back(layer.id);
}
QCOMPARE(int(folders.size()), 2);
for (const QUuid &folder : folders)
QCOMPARE(int(session->descendantIDs(folder).size()), 1);
}
void LayerDragTests::aMaskDragCopiesTheMaskOntoTheRowUnderIt()
+126
View File
@@ -0,0 +1,126 @@
#include "Document/EditorSession.h"
#include "SessionFixtures.h"
#include <QtTest>
// Duplicating layers and folders in the session.
class LayerDuplicateTests : public QObject {
Q_OBJECT
private slots:
void duplicatingALayerByDraggingPlacesTheCopyAsOneStep();
void aFolderCopyKeepsLinksInsideAndStopsAtTheLimit();
};
void LayerDuplicateTests::duplicatingALayerByDraggingPlacesTheCopyAsOneStep()
{
EditorSession session;
session.createDocument(800, 600);
for (int each = 0; each < 3; ++each)
session.addBlankLayer();
const std::vector<LayerHierarchy::Entry> rows = session.layerRows();
const ProjectLayerRecord top = rows.front().layer, bottom = rows.back().layer;
const std::optional<CanvasDocument> before = session.document();
const int count = session.history.undoCount();
QVERIFY(session.duplicateLayer(bottom.id, std::nullopt, top.id));
const std::vector<LayerHierarchy::Entry> after = session.layerRows();
QCOMPARE(after.size(), size_t(4));
QCOMPARE(session.history.undoCount(), count + 1);
QCOMPARE(session.history.undoName(), QString("Duplicate Layer"));
QCOMPARE(after.front().layer.name, bottom.name + " copy");
QCOMPARE(after.front().layer.id, session.activeLayerID());
QVERIFY(indexOf(session.document().value().layers, bottom.id) >= 0);
session.undo();
QCOMPARE(session.document(), before);
session.addGroup();
const QUuid folder = session.activeLayerID().value();
// A stranger or a place inside itself is refused.
QVERIFY(!session.duplicateLayer(QUuid::createUuid(), std::nullopt));
QVERIFY(!session.duplicateLayer(bottom.id, bottom.id));
QVERIFY(!session.duplicateLayer(folder, folder));
QCOMPARE(session.layerRows().size(), size_t(4));
// Into a folder, at its top.
QVERIFY(session.duplicateLayer(bottom.id, folder));
const QUuid inside = session.activeLayerID().value();
QCOMPARE(layerWith(session, inside).parentID, std::optional(folder));
session.setIsImporting(true);
QVERIFY(!session.duplicateLayer(bottom.id, std::nullopt));
session.setIsImporting(false);
// A folder copies with what it holds, at the bottom.
session.toggleGroupExpansion(folder);
const int layers = int(session.document().value().layers.size());
QVERIFY(session.duplicateLayer(folder, std::nullopt, std::nullopt, true));
const ImageLayer copy = layerWith(session, session.activeLayerID().value());
QVERIFY(copy.isGroup && copy.id != folder && !copy.parentID);
QCOMPARE(copy.name, layerWith(session, folder).name + " copy");
const QSet<QUuid> held = session.descendantIDs(copy.id);
QCOMPARE(int(held.size()), 1);
const ImageLayer child = layerWith(session, *held.begin());
// The child keeps its name and moves to the copy.
QVERIFY(child.id != inside && child.name == layerWith(session, inside).name && child.parentID == copy.id);
QVERIFY(session.collapsedGroupIDs().contains(copy.id));
QCOMPARE(int(session.document().value().layers.size()), layers + 2);
QCOMPARE(session.layerRows().back().layer.id, copy.id);
session.undo();
QCOMPARE(int(session.document().value().layers.size()), layers);
// The copy sits right above its source.
session.selectLayer(top.id);
session.duplicateActiveLayer();
const int original = indexOf(session.document().value().layers, top.id);
QCOMPARE(session.document().value().layers[original + 1].name, top.name + " copy");
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[original + 1].id));
}
void LayerDuplicateTests::aFolderCopyKeepsLinksInsideAndStopsAtTheLimit()
{
EditorSession session;
session.createDocument(40, 40, true);
const QUuid base = session.activeLayerID().value();
QImage white(10, 10, QImage::Format_RGBA8888_Premultiplied);
white.fill(Qt::white);
session.insert(ImportedImage(white, white, QStringLiteral("White")));
const QUuid clipped = session.activeLayerID().value();
QVERIFY(session.canToggleClippingMask(clipped));
session.toggleClippingMask(clipped);
session.selectLayers({base, clipped}, clipped);
session.groupSelectedLayers();
const QUuid folder = session.activeLayerID().value();
session.duplicateActiveLayer();
const QUuid copy = session.activeLayerID().value();
QVERIFY(copy != folder);
// Links inside the copy point inside the copy.
std::optional<QUuid> copiedBase, copiedClip;
for (const QUuid &id : session.descendantIDs(copy)) {
const ImageLayer layer = layerWith(session, id);
(layer.maskSourceID ? copiedClip : copiedBase) = id;
}
QCOMPARE(layerWith(session, copiedClip.value()).maskSourceID, copiedBase);
QVERIFY(copiedBase != base);
QCOMPARE(layerWith(session, clipped).maskSourceID, std::optional(base));
// A copy alone keeps links and a parent outside it.
session.selectLayer(clipped);
session.duplicateActiveLayer();
const ImageLayer lone = layerWith(session, session.activeLayerID().value());
QVERIFY(lone.id != clipped && lone.maskSourceID == base && lone.parentID == folder);
session.undo();
// Past 10,000 layers nothing is copied or recorded.
ProjectSnapshot snapshot = session.projectSnapshot().value();
// Three copies into 9,997 reach 10,000 exactly: allowed.
while (snapshot.manifest.layers.size() < 9'997)
snapshot.manifest.layers.push_back(ProjectLayerRecord{.id = QUuid::createUuid(), .name = "Blank", .isVisible = true,
.transform = {.origin = {0, 0}, .size = {40, 40}}, .imageFile = std::nullopt});
session.installProject(snapshot, QString());
session.selectLayer(folder);
session.duplicateActiveLayer();
QVERIFY(session.activeLayerID() != folder);
QCOMPARE(int(session.document().value().layers.size()), 10'000);
// Past it nothing is copied or recorded.
session.selectLayer(folder);
const int steps = session.history.undoCount();
session.duplicateActiveLayer();
QCOMPARE(session.activeLayerID(), std::optional(folder));
QCOMPARE(int(session.document().value().layers.size()), 10'000);
QCOMPARE(session.history.undoCount(), steps);
QVERIFY(!session.duplicateLayer(folder, std::nullopt, std::nullopt, true));
}
QTEST_GUILESS_MAIN(LayerDuplicateTests)
#include "LayerDuplicateTests.moc"
+2 -2
View File
@@ -160,7 +160,7 @@ void LayerMaskSessionTests::projectAndPNGPreserveCoverageAndDisabledState()
rewrite(*session, [&](ProjectSnapshot &snapshot) { record(snapshot, id).maskEnabled = enabled; });
ProjectStore::save(session->projectSnapshot().value(), path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(loaded.manifest.version, qint64(7));
QCOMPARE(loaded.manifest.version, qint64(8));
session->installProject(loaded, path);
QCOMPARE(session->activeLayer().value().mask.value().isEnabled, enabled);
const QImage image = QImage::fromData(ImageExporter::pngData(loaded), "PNG");
@@ -240,7 +240,7 @@ void LayerMaskSessionTests::folderMasksSaveResizeAndNeedTheNewFormat()
const ProjectSnapshot snapshot = session->projectSnapshot().value();
ProjectStore::save(snapshot, path);
const ProjectSnapshot loaded = ProjectStore::load(path);
QVERIFY(loaded.manifest.version == 7 && loaded.masks.count(folder) == 1);
QVERIFY(loaded.manifest.version == 8 && loaded.masks.count(folder) == 1);
QCOMPARE(alphas(ImageExporter::render(loaded).image), (QList<int>{255, 0, 128, 255}));
const ProjectSnapshot resized = ImageResizer::resize(loaded, {.width = 4, .height = 4, .resolution = 72, .sampling = LayerSampling::nearest});
QCOMPARE(alphas(ImageExporter::render(resized).image),
+3
View File
@@ -350,6 +350,9 @@ void NativeLayerListTests::layerListToolKeysAndTransformNudge()
shown.list.setFocus();
QTest::keyClick(&shown.list, Qt::Key_H);
QCOMPARE(session.tool(), NavigationTool::hand);
// T chooses Type, since Swift 1.1.6.
QTest::keyClick(&shown.list, Qt::Key_T);
QCOMPARE(session.tool(), NavigationTool::type);
QTest::keyClick(&shown.list, Qt::Key_V);
QCOMPARE(session.tool(), NavigationTool::move);
session.beginTransform();
+5 -3
View File
@@ -309,10 +309,10 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
QTest::addColumn<std::optional<ProjectError::Kind>>("refused");
const auto invalid = std::optional(ProjectError::Kind::invalid), tooLarge = std::optional(ProjectError::Kind::tooLarge);
const std::optional<ProjectError::Kind> fine;
for (int version = 1; version <= 7; ++version)
for (int version = 1; version <= 8; ++version)
QTest::addRow("plain at version %d", version) << "" << version << fine;
QTest::newRow("version 0") << "" << 0 << std::optional(ProjectError::Kind::version);
QTest::newRow("version 8") << "" << 8 << std::optional(ProjectError::Kind::version);
QTest::newRow("version 9") << "" << 9 << std::optional(ProjectError::Kind::version);
QTest::newRow("another format") << "format" << 7 << invalid;
QTest::newRow("another colour space") << "colorSpace" << 7 << invalid;
QTest::newRow("resolution 0.5") << "resolution=0.5" << 7 << invalid;
@@ -349,7 +349,9 @@ void ProjectManifestTests::validationFollowsTheFormatsVersions_data()
QTest::newRow("an empty folder at version 1") << "folderEmpty" << 1 << invalid;
QTest::newRow("an empty folder at version 2") << "folderEmpty" << 2 << fine;
QTest::newRow("a folder that blends") << "folderBlend" << 7 << invalid;
QTest::newRow("a folder that fades") << "folderOpacity" << 7 << invalid;
QTest::newRow("a folder that fades before version 8") << "folderOpacity" << 7 << invalid;
QTest::newRow("a folder that fades at version 8") << "folderOpacity" << 8 << fine;
QTest::newRow("a folder that blends at version 8") << "folderBlend" << 8 << invalid;
QTest::newRow("a folder with pixels") << "folderPixels" << 7 << invalid;
QTest::newRow("a folder mask before version 6") << "folderMask" << 5 << invalid;
QTest::newRow("a folder mask at version 6") << "folderMask" << 6 << fine;
+1 -1
View File
@@ -122,7 +122,7 @@ void ProjectSessionTests::aSnapshotGivesBackWhatWasInstalled()
const ProjectSnapshot again = session.projectSnapshot().value();
// The same manifest to the byte; pixels by identity.
QCOMPARE(again.manifest.encoded(), snapshot.manifest.encoded());
QCOMPARE(again.manifest.version, qint64(7));
QCOMPARE(again.manifest.version, qint64(8));
QCOMPARE(int(again.images.size()), 2);
QCOMPARE(int(again.masks.size()), 2);
for (const auto &[id, image] : snapshot.images)
+9 -9
View File
@@ -167,7 +167,7 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
};
// A version out of range is named whatever else fails.
const QByteArray future = R"({"format": "com.compositor.project", "version": 42, "layers": "from the future"})";
for (const qint64 strange : {qint64(42), qint64(8), qint64(0)}) {
for (const qint64 strange : {qint64(42), qint64(9), qint64(0)}) {
try {
overwrite(metadata, QByteArray(future).replace("42", QByteArray::number(strange)));
ProjectStore::load(path);
@@ -182,12 +182,12 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
QCOMPARE(loading(QByteArray(future).replace("42", "1")), std::optional(ProjectError::Kind::invalid));
// Another format is invalid whatever version it claims.
QCOMPARE(loading(QByteArray(future).replace("compositor", "example")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 0")), std::optional(ProjectError::Kind::version));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 0")), std::optional(ProjectError::Kind::version));
// Swift's Int holds 64 bits, each one: no double between.
const qint64 most = std::numeric_limits<qint64>::max(), least = std::numeric_limits<qint64>::min();
for (const qint64 wild : {qint64(2147483648), qint64(-2147483648), qint64(-1), qint64(9007199254740993), most, least}) {
try {
overwrite(metadata, QByteArray(good).replace("\"version\": 7", "\"version\": " + QByteArray::number(wild)));
overwrite(metadata, QByteArray(good).replace("\"version\": 8", "\"version\": " + QByteArray::number(wild)));
ProjectStore::load(path);
QFAIL("a wild version opened");
} catch (const ProjectError &error) {
@@ -197,19 +197,19 @@ void ProjectTests::unsupportedCorruptAndUnsafeMetadataAreRejected()
}
}
QCOMPARE(loading(QByteArray(good).replace("\"width\": 100", "\"width\": 3000000000")), std::optional(ProjectError::Kind::tooLarge));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 9223372036854775808")), std::optional(ProjectError::Kind::invalid));
// Declared: Qt rounds a token just past Int64's minimum.
try {
overwrite(metadata, QByteArray(good).replace("\"version\": 7", "\"version\": -9223372036854775809"));
overwrite(metadata, QByteArray(good).replace("\"version\": 8", "\"version\": -9223372036854775809"));
ProjectStore::load(path);
QFAIL("a version below every integer opened");
} catch (const ProjectError &error) {
QCOMPARE(error.version, std::optional(least));
}
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 7", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 1e19")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 7.5")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": 1e300")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("\"version\": 8", "\"version\": \"7\"")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading(QByteArray(good).replace("com.compositor.project", "com.example.project")), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading("not json"), std::optional(ProjectError::Kind::invalid));
QCOMPARE(loading("[1, 2]"), std::optional(ProjectError::Kind::invalid));
+6 -3
View File
@@ -168,9 +168,12 @@ void TransformOverlayTests::handlesAndGuidesArePaintedWhereTheyBelong()
QCOMPARE(shot.pixelColor(190, 140), QColor(Qt::white));
QCOMPARE(shot.pixelColor(210, 160), QColor(Qt::white));
QCOMPARE(shot.pixelColor(200, 112), QColor(Qt::white));
// The accent outline runs between the handles, over the red.
QVERIFY(shot.pixelColor(190, 145) != QColor(Qt::red));
QVERIFY(shot.pixelColor(190, 145).blue() > shot.pixelColor(190, 145).red());
// The accent alone runs between handles, half over red.
const QColor accent = canvas.palette().color(QPalette::Highlight);
const QColor edge = shot.pixelColor(190, 145);
QVERIFY2(std::abs(edge.red() - (accent.red() + 255) / 2) <= 2 && std::abs(edge.green() - accent.green() / 2) <= 2
&& std::abs(edge.blue() - accent.blue() / 2) <= 2,
qPrintable(edge.name()));
session.setShowsTransformControls(false);
QVERIFY(overlay.drawnRect(canvas.rect()).isEmpty());
shot = canvas.grab().toImage();
+12
View File
@@ -73,6 +73,18 @@ void TransformSnapTests::snappedMoveNudgesWithinToleranceAndKeepsTheGuides()
const LayerTransform near{.origin = {77, 20}, .size = {20, 10}};
QCOMPARE(session.snappedMove(near, {a}, 2).origin, QPointF(75, 20));
QCOMPARE(session.snappedMove(near, {a, b}, 2).origin, QPointF(77, 20));
// View > Snap off: every draft stays put, guides gone.
const LayerTransform snapped = session.snappedMove(draft, {a}, 5);
QVERIFY(snapped != draft && !session.snapGuides.xs.empty());
QSignalSpy changes(&session, &EditorSession::changed);
session.setSnappingEnabled(false);
QVERIFY(!session.snappingEnabled() && changes.count() == 1);
QCOMPARE(session.snapGuides, SnapGuides{});
session.snapGuides = SnapGuides{{1}, {2}};
QCOMPARE(session.snappedMove(draft, {a}, 5), draft);
QCOMPARE(session.snapGuides, SnapGuides{});
session.setSnappingEnabled(true);
QCOMPARE(session.snappedMove(draft, {a}, 5), snapped);
}
void TransformSnapTests::dragsEditsAreNotPersistentAndEndsClearTheGuides()
+38 -12
View File
@@ -24,7 +24,7 @@ private slots:
void moveRotateAndShiftConstraints();
void previewCommitCancelAndUndoPreserveSources();
void duplicateTransformPreservesOriginalAndSupportsUndoAndCancel();
void duplicateTransformCopiesEveryPixelLayerButNoFolder();
void duplicateTransformCopiesLayersAndFoldersWithTheirContents();
void scalePercentSetsBothSidesAboutTheCenter();
void noOpInvalidValuesAndSwitchingTools();
void rotatedHitTesting();
@@ -373,7 +373,7 @@ void TransformTests::duplicateTransformPreservesOriginalAndSupportsUndoAndCancel
QCOMPARE(int(session.document().value().layers.size()), 1);
}
void TransformTests::duplicateTransformCopiesEveryPixelLayerButNoFolder()
void TransformTests::duplicateTransformCopiesLayersAndFoldersWithTheirContents()
{
EditorSession session;
session.createDocument(400, 200);
@@ -383,16 +383,16 @@ void TransformTests::duplicateTransformCopiesEveryPixelLayerButNoFolder()
const QUuid second = session.activeLayerID().value();
session.addGroup();
const QUuid folder = session.activeLayerID().value();
// A folder has no pixels to copy; layers do.
// Every selected root copies, an empty folder too.
session.selectLayers({first, second, folder}, first);
session.beginDuplicateTransform();
QCOMPARE(int(session.document().value().layers.size()), 5);
QCOMPARE(session.selectedLayerIDs().size(), 2);
QVERIFY(!session.selectedLayerIDs().contains(first) && !session.selectedLayerIDs().contains(second));
QCOMPARE(int(session.document().value().layers.size()), 6);
QCOMPARE(session.selectedLayerIDs().size(), 3);
QVERIFY(!session.selectedLayerIDs().contains(first) && !session.selectedLayerIDs().contains(folder));
// The topmost copy leads; copies sit above their sources.
QCOMPARE(session.document().value().layers[1].name, QString("Painted layer copy"));
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[3].id));
QVERIFY(session.transformsAsGroup());
QCOMPARE(session.document().value().layers[5].name, QString("Folder 1 copy"));
QCOMPARE(session.activeLayerID(), std::optional(session.document().value().layers[5].id));
session.cancelTransform();
QCOMPARE(int(session.document().value().layers.size()), 3);
QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second, folder}));
@@ -402,13 +402,21 @@ void TransformTests::duplicateTransformCopiesEveryPixelLayerButNoFolder()
session.commitTransform();
QCOMPARE(session.history.undoName(), QString("Duplicate Layers"));
QCOMPARE(int(session.document().value().layers.size()), 5);
// A folder alone has no pixels: nothing begins.
// A folder carries its contents; a selected child rides along.
QVERIFY(session.placeLayer(second, folder));
session.selectLayer(folder);
QVERIFY(session.canTransform());
session.toggleGroupExpansion(folder);
session.selectLayers({folder, second}, folder);
session.beginDuplicateTransform();
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(int(session.document().value().layers.size()), 7);
const QUuid copy = session.activeLayerID().value();
QCOMPARE(session.selectedLayerIDs(), QSet<QUuid>{copy});
const std::vector<ImageLayer> &shown = session.document().value().layers;
QVERIFY(shown[size_t(indexOf(shown, copy))].isGroup && session.descendantIDs(copy).size() == 1);
QVERIFY(session.collapsedGroupIDs().contains(copy));
session.cancelTransform();
QCOMPARE(int(session.document().value().layers.size()), 5);
QCOMPARE(session.collapsedGroupIDs(), QSet<QUuid>{folder});
QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{folder, second}));
QVERIFY(session.canUndo());
session.setIsProjectBusy(true);
session.selectLayer(first);
@@ -417,6 +425,24 @@ void TransformTests::duplicateTransformCopiesEveryPixelLayerButNoFolder()
session.setIsProjectBusy(false);
QVERIFY(session.canUndo());
QCOMPARE(int(session.document().value().layers.size()), 5);
// Past 10,000 layers nothing copies and nothing is recorded.
ProjectSnapshot full = session.projectSnapshot().value();
while (full.manifest.layers.size() < 10'000)
full.manifest.layers.push_back(ProjectLayerRecord{.id = QUuid::createUuid(), .name = "Blank", .isVisible = true,
.transform = {.origin = {0, 0}, .size = {40, 40}}, .imageFile = std::nullopt});
session.installProject(full, QString());
session.selectLayers({first, second}, second);
const int steps = session.history.undoCount();
session.beginDuplicateTransform();
QVERIFY(!session.transformEdit().has_value());
QCOMPARE(session.selectedLayerIDs(), (QSet<QUuid>{first, second}));
QCOMPARE(session.activeLayerID(), std::optional(second));
QCOMPARE(session.history.undoCount(), steps);
// No transaction stays open: the next edit steps.
session.selectLayer(first);
session.setLayerOpacity(0.3);
QVERIFY(session.canUndo());
QCOMPARE(session.history.undoCount(), steps + 1);
}
QTEST_GUILESS_MAIN(TransformTests)
+1 -1
View File
@@ -404,7 +404,7 @@ void TypeControlsTests::aTextRowShowsAndOpensItsText()
QVERIFY(QTest::qWaitForWindowActive(&list));
LayerCell &textRow = *list.cells().at(0), &blankRow = *list.cells().at(1);
const auto dimensions = [](LayerCell &row) { return row.findChild<QLabel *>("layerDimensions")->text(); };
QCOMPARE(dimensions(textRow), QString("Text · Double-click to edit"));
QCOMPARE(dimensions(textRow), QString("Text"));
QCOMPARE(dimensions(blankRow), QString("400 × 300 px"));
QCOMPARE(textRow.thumbnail().toolTip(), QString("Editable text layer"));
QCOMPARE(textRow.thumbnail().accessibleName(), QString("Select text: Row"));
+58
View File
@@ -0,0 +1,58 @@
#include "MenuFixtures.h"
// The View menu: zoom, the pixel grid and Snap.
class ViewMenuTests : public QObject {
Q_OBJECT
private slots:
void viewEntriesZoomTheFrontSession();
void thePixelGridEntryTogglesTheSession();
};
void ViewMenuTests::viewEntriesZoomTheFrontSession()
{
Bar bar;
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
QVERIFY(!bar.action(name).isEnabled());
bar.session().createDocument(400, 300);
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
QVERIFY(bar.action(name).isEnabled());
bar.session().zoom(0.5);
bar.action("actualPixels").trigger();
QCOMPARE(bar.session().viewport.zoom(), 1.0);
bar.action("zoomIn").trigger();
QCOMPARE(bar.session().viewport.zoom(), 1.25);
bar.action("zoomOut").trigger();
bar.action("zoomOut").trigger();
QCOMPARE(bar.session().viewport.zoom(), 0.8);
bar.action("fit").trigger();
QVERIFY(bar.session().viewport.followsFit());
}
void ViewMenuTests::thePixelGridEntryTogglesTheSession()
{
Bar bar;
QAction &grid = bar.action("pixelGrid");
QVERIFY(grid.isCheckable() && grid.isChecked() && grid.isEnabled());
QCOMPARE(grid.text(), QString("Pixel Grid (800% and above)"));
grid.trigger();
QVERIFY(!bar.session().showsPixelGrid() && !grid.isChecked());
grid.trigger();
QVERIFY(bar.session().showsPixelGrid() && grid.isChecked());
// The session's own change reaches the mark.
bar.session().setShowsPixelGrid(false);
QVERIFY(!grid.isChecked());
bar.workspace.newCanvas();
QVERIFY(grid.isChecked() && grid.isEnabled());
// Snap, beneath it, follows the session the same way.
QAction &snap = bar.action("snap");
QVERIFY(snap.isCheckable() && snap.isChecked() && snap.isEnabled() && snap.text() == "Snap");
snap.trigger();
QVERIFY(!bar.session().snappingEnabled() && !snap.isChecked());
snap.trigger();
QVERIFY(bar.session().snappingEnabled() && snap.isChecked());
bar.session().setSnappingEnabled(false);
QVERIFY(!snap.isChecked());
}
QTEST_MAIN(ViewMenuTests)
#include "ViewMenuTests.moc"