Acceptance run with screenshots and the MVP table; window teardown no longer reaches destroyed menus or toolbar (11.5)

This commit is contained in:
雷电芽衣 committed 2026-09-30 14:39:11 -04:00
1 parent 51bec23cc5
commit e94fd73573
30 files changed
+791 -3

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
#include "AcceptanceFixtures.h"
#include "Document/HueSaturation.h"
#include "Document/LayerEffects.h"
// The acceptance run, part two: adjustments, Camera Raw, effects, filters.
class AcceptanceColorTests : public QObject {
Q_OBJECT
private slots:
void initTestCase() { QStandardPaths::setTestModeEnabled(true); }
void adjustmentsAsEditsAndLayers();
void cameraRawDocked();
void layerEffects();
void filters();
};
namespace {
// The photo, alone in a document its size.
QUuid photo(App &app)
{
if (!scene(1200, 800).save(app.path("photo.png")))
throw std::runtime_error("the photo could not be written");
app.session().createDocument(1200, 800);
app.importFile(app.path("photo.png"));
return app.session().activeLayerID().value();
}
}
void AcceptanceColorTests::adjustmentsAsEditsAndLayers()
{
App app;
const QUuid layer = photo(app);
QCOMPARE(app.shown(600, 400), QColor(220, 30, 30));
// Hue/Saturation as an edit: the red disc turns green.
app.action("hueSaturation").trigger();
QVERIFY(app.session().hueSaturation());
app.session().updateHueSaturation(HueSaturationSettings(120), true);
bool done = false;
app.session().commitHueSaturation([&done] { done = true; });
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
const QColor green = pixel(app.session(), layer, 600, 400);
QVERIFY2(green.green() > 200 && green.red() < 60, qPrintable(green.name()));
// Invert as a layer: the pixels stay, the view inverts.
app.action("newInvertAdjustment").trigger();
QVERIFY(app.session().activeLayer().value().adjustment);
QCOMPARE(pixel(app.session(), layer, 600, 400), green);
const QColor inverted = app.shown(600, 400);
QVERIFY2(qAbs(inverted.red() - (255 - green.red())) <= 1 && qAbs(inverted.green() - (255 - green.green())) <= 1, qPrintable(inverted.name()));
// Curves as a layer opens its editor in the panel.
app.action("newCurvesAdjustment").trigger();
QTRY_VERIFY_WITH_TIMEOUT(app.session().filterEdit(), 30000);
QCOMPARE(app.session().filterEdit().value().kind, FilterKind::curves);
app.shot(QStringLiteral("05-adjustments"));
app.session().cancelFilter();
QCOMPARE(int(app.session().document().value().layers.size()), 3);
}
void AcceptanceColorTests::cameraRawDocked()
{
App app;
const QUuid layer = photo(app);
app.action("cameraRawFilter").trigger();
QVERIFY(app.session().filterEdit());
QWidget *const dock = app.window.findChild<QWidget *>(QStringLiteral("panelDock"));
QVERIFY(dock && dock->isVisible());
FilterSettings settings = app.session().filterSettings();
settings.cameraRaw.exposure = 1;
app.session().updateFilter(settings, true);
QTRY_VERIFY_WITH_TIMEOUT(app.session().filterEdit().value().previewImage(layer), 30000);
app.shot(QStringLiteral("06-camera-raw"));
bool done = false;
app.session().commitFilter([&done] { done = true; });
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
// A stop brighter: the ground's green lifts.
const QColor ground = pixel(app.session(), layer, 600, 700);
QVERIFY2(ground.green() > 170, qPrintable(ground.name()));
}
void AcceptanceColorTests::layerEffects()
{
App app;
app.session().createDocument(1200, 800);
app.session().selectTool(NavigationTool::shape);
app.session().setForegroundColor({0.2, 0.5, 0.9});
app.drag(QPointF(300, 200), QPointF(900, 600));
const QUuid shape = app.session().activeLayerID().value();
// A red stroke and a drop shadow.
app.session().addEffect(LayerEffectKind::stroke);
app.session().changeEffects([](LayerEffects &effects) {
effects.stroke.value().size = 12;
effects.stroke.value().red = 1;
effects.stroke.value().green = 0;
effects.stroke.value().blue = 0;
});
app.session().finishEffectsEditing(true);
app.session().addEffect(LayerEffectKind::shadow);
QVERIFY(app.session().effectsEditing());
app.shot(QStringLiteral("07-effects"));
app.session().finishEffectsEditing(true);
const LayerEffects effects = app.session().activeLayer().value().effects.value();
QVERIFY(effects.stroke && effects.shadow);
QCOMPARE(app.session().activeLayerID(), std::optional<QUuid>(shape));
// The stroke rings the shape outside its edge.
QCOMPARE(app.shown(300 - 6, 400), QColor(255, 0, 0));
QCOMPARE(app.shown(600, 400), QColor(0x33, 0x80, 0xe6));
}
void AcceptanceColorTests::filters()
{
App app;
const QUuid layer = photo(app);
// Dither: black and white dots by default.
app.action("dither").trigger();
QTRY_VERIFY_WITH_TIMEOUT(app.session().filterEdit() && app.session().filterEdit().value().previewImage(layer), 30000);
app.shot(QStringLiteral("08-filters-dither"));
bool done = false;
app.session().commitFilter([&done] { done = true; });
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
for (const QPoint at : {QPoint(100, 100), QPoint(600, 400), QPoint(900, 700)}) {
const QColor dot = pixel(app.session(), layer, at.x(), at.y());
QVERIFY2(dot == QColor(Qt::black) || dot == QColor(Qt::white), qPrintable(dot.name()));
}
// Remove Background masks the photo round its subject.
app.action("undo").trigger();
app.action("removeBackground").trigger();
QVERIFY(app.session().filterEdit());
done = false;
app.session().commitFilter([&done] { done = true; });
QTRY_VERIFY_WITH_TIMEOUT(done, 60000);
app.settle();
QVERIFY(app.session().activeLayer().value().mask);
QCOMPARE(app.shown(600, 400).alpha(), 255);
QCOMPARE(app.shown(40, 40).alpha(), 0);
app.shot(QStringLiteral("09-remove-background"));
}
QTEST_MAIN(AcceptanceColorTests)
#include "AcceptanceColorTests.moc"
+121
View File
@@ -0,0 +1,121 @@
#pragma once
#include "Document/ProjectWorkspace.h"
#include "IO/ImageExporter.h"
#include "Rendering/EditorCanvas.h"
#include "UI/CompositorMenus.h"
#include "UI/OmarchyTheme.h"
#include "UI/ProjectWorkspaceView.h"
#include <QDir>
#include <QPainter>
#include <QTemporaryDir>
#include <QtTest>
// The acceptance run drives the app's own window.
struct App {
QTemporaryDir files;
// As the app starts: the theme before the window.
OmarchyTheme theme{files.filePath(QStringLiteral("omarchy"))};
ProjectWorkspace workspace;
ProjectWorkspaceView window{workspace};
App()
{
window.resize(1180, 780);
window.show();
if (!QTest::qWaitForWindowActive(&window))
throw std::runtime_error("the window never became active");
}
EditorSession &session() { return workspace.current().session; }
CanvasView &canvas() { return *window.findChild<CanvasView *>(); }
QAction &action(const char *name)
{
QAction *found = window.findChild<CompositorMenus *>()->action(QString::fromLatin1(name));
if (!found)
throw std::runtime_error(std::string("no entry named ") + name);
return *found;
}
// A document point where the canvas shows it.
QPoint at(QPointF point)
{
return session().viewport.viewPoint(point, session().document().value().size()).toPoint();
}
void press(QPointF point, Qt::KeyboardModifiers modifiers = Qt::NoModifier)
{
QTest::mousePress(&canvas(), Qt::LeftButton, modifiers, at(point));
}
void move(QPointF point)
{
const QPointF view = at(point);
QMouseEvent event(QEvent::MouseMove, view, view, canvas().mapToGlobal(view.toPoint()), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(&canvas(), &event);
}
void release(QPointF point) { QTest::mouseRelease(&canvas(), Qt::LeftButton, Qt::NoModifier, at(point)); }
// A drag in eight steps, as a hand makes it.
void drag(QPointF from, QPointF to)
{
press(from);
for (int step = 1; step <= 8; ++step)
move(from + (to - from) * (step / 8.0));
release(to);
}
// Waits for the project to be free again.
void settle() { QTRY_VERIFY_WITH_TIMEOUT(!session().isProjectBusy(), 30000); }
QString path(const QString &name) const { return files.filePath(name); }
// The window and its shown panels, saved when asked for.
void shot(const QString &name)
{
const QByteArray folder = qgetenv("ACCEPTANCE_SHOTS");
if (folder.isEmpty())
return;
// Posted geometry and previews land first.
QTest::qWait(300);
QImage image = window.grab().toImage();
QPainter painter(&image);
for (QWidget *panel : QApplication::topLevelWidgets())
if (panel != &window && panel->isVisible() && panel->parentWidget() && panel->parentWidget()->window() == &window)
painter.drawPixmap(panel->geometry().topLeft() - window.geometry().topLeft(), panel->grab());
painter.end();
QDir().mkpath(QString::fromLocal8Bit(folder));
if (!image.save(QDir(QString::fromLocal8Bit(folder)).filePath(name + QStringLiteral(".png"))))
throw std::runtime_error("the screenshot could not be written");
}
// The document as an export draws it.
QColor shown(int x, int y) { return ImageExporter::render(session().projectSnapshot().value()).image.pixelColor(x, y); }
void importFile(const QString &file)
{
bool done = false;
session().importImages({QUrl::fromLocalFile(file)}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
}
};
// A photograph's stand-in: sky, ground and a red disc.
inline QImage scene(int width, int height)
{
QImage image(width, height, QImage::Format_RGB32);
QPainter painter(&image);
QLinearGradient sky(0, 0, 0, height);
sky.setColorAt(0, QColor(40, 90, 200));
sky.setColorAt(1, QColor(170, 210, 250));
painter.fillRect(image.rect(), sky);
painter.fillRect(QRect(0, height * 3 / 4, width, height / 4), QColor(60, 140, 70));
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(220, 30, 30));
painter.drawEllipse(QPointF(width / 2.0, height / 2.0), height / 4.0, height / 4.0);
return image;
}
// A layer's pixel at a document point; clear outside it.
inline QColor pixel(const EditorSession &session, QUuid id, int x, int y)
{
for (const ImageLayer &layer : session.document().value().layers) {
if (layer.id != id)
continue;
const QImage image = layer.asset.value().image();
const QPointF unit = (QPointF(x + 0.5, y + 0.5) - layer.transform.origin);
const QPoint at(int(std::floor(unit.x() * image.width() / layer.transform.size.width())),
int(std::floor(unit.y() * image.height() / layer.transform.size.height())));
return image.rect().contains(at) ? image.pixelColor(at) : QColor(0, 0, 0, 0);
}
throw std::runtime_error("no such layer");
}
+216
View File
@@ -0,0 +1,216 @@
#include "AcceptanceFixtures.h"
#include "DNGFixture.h"
#include "DialogDesk.h"
#include "IO/ProjectStore.h"
#include "PSDFixture.h"
#include "UI/ColorPickerSheet.h"
#include <QMenu>
// The acceptance run, part three: sizes, exports, imports, projects, theme.
class AcceptanceProjectTests : public QObject {
Q_OBJECT
private slots:
void initTestCase() { QStandardPaths::setTestModeEnabled(true); }
void cropSizesAndExports();
void imports();
void projectsRecentScrubAndReload();
void omarchyTheme();
};
namespace {
QUuid photo(App &app)
{
if (!scene(1200, 800).save(app.path("photo.png")))
throw std::runtime_error("the photo could not be written");
app.session().createDocument(1200, 800);
app.importFile(app.path("photo.png"));
return app.session().activeLayerID().value();
}
// Types into a sheet's field, as the resize tests do.
void type(App &app, const char *name, const QString &text)
{
auto *field = app.window.findChild<QDialog *>()->findChild<PickerField *>(QString::fromLatin1(name));
if (!field)
throw std::runtime_error(std::string("no field named ") + name);
field->setFocus();
field->selectAll();
QTest::keyClicks(field, text);
QTest::keyClick(field, Qt::Key_Return);
}
QSize document(App &app)
{
return QSize(int(app.session().document().value().width), int(app.session().document().value().height));
}
}
void AcceptanceProjectTests::cropSizesAndExports()
{
App app;
photo(app);
ProjectController &controller = app.workspace.current().controller;
// Crop to the middle.
app.session().selectTool(NavigationTool::crop);
app.session().setCropRect(QRectF(100, 100, 1000, 600));
bool done = false;
app.session().commitCrop([&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(document(app), QSize(1000, 600));
// Canvas Size widens it; Trim takes the clear edges back.
done = false;
controller.canvasSize([&done] { done = true; });
QTRY_VERIFY(app.window.findChild<QDialog *>());
app.shot(QStringLiteral("10-canvas-size"));
type(app, "canvasWidth", QStringLiteral("1400"));
QTRY_VERIFY(done);
QCOMPARE(document(app), QSize(1400, 600));
done = false;
controller.trim([&done] { done = true; });
QTRY_VERIFY(app.window.findChild<QDialog *>());
QTest::keyClick(app.window.findChild<QDialog *>(), Qt::Key_Return);
QTRY_VERIFY(done);
// The crop hid the photo's edges; Trim finds them again.
QCOMPARE(document(app), QSize(1200, 600));
// Image Size halves it.
done = false;
controller.imageSize([&done] { done = true; });
QTRY_VERIFY(app.window.findChild<QDialog *>());
type(app, "imageWidth", QStringLiteral("600"));
QTRY_VERIFY(done);
QCOMPARE(document(app), QSize(600, 300));
// PNG and JPEG exports, through their panels and sheet.
DialogDesk desk;
desk.replies = {app.path("scene.png")};
done = false;
controller.exportPNG([&done] { done = true; });
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
QCOMPARE(QImage(app.path("scene.png")).size(), QSize(600, 300));
desk.replies = {app.path("scene.jpg")};
done = false;
controller.exportJPEG([&done] { done = true; });
QTRY_VERIFY(app.window.findChild<QDialog *>());
app.shot(QStringLiteral("11-export-jpeg"));
QPushButton *exported = app.window.findChild<QDialog *>()->findChild<QPushButton *>(QStringLiteral("jpegExport"));
QVERIFY(exported);
QTRY_VERIFY_WITH_TIMEOUT(exported->isEnabled(), 30000);
exported->click();
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
QCOMPARE(QImage(app.path("scene.jpg")).size(), QSize(600, 300));
}
void AcceptanceProjectTests::imports()
{
App app;
app.session().createDocument(800, 600);
QVERIFY(scene(200, 150).save(app.path("Scene.jpg")));
QVERIFY(scene(200, 150).save(app.path("Scene.png")));
QVERIFY(scene(200, 150).save(app.path("Scene.tif")));
QVERIFY(QFile::copy(QFINDTESTDATA("fixtures/red.heic"), app.path("Red.heic")));
QFile svg(app.path("Mark.svg"));
QVERIFY(svg.open(QIODevice::WriteOnly));
svg.write(R"(<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><circle cx="60" cy="40" r="30" fill="#e03020"/></svg>)");
svg.close();
PSDRecord sky = PSDFixture::record(QStringLiteral("Sky"), PSDFixture::colorImage(40, 20, 0, 0, 1), QRectF(0, 0, 40, 20));
QFile psd(app.path("Trip.psd"));
QVERIFY(psd.open(QIODevice::WriteOnly));
psd.write(PSDFixture::data(PSDDocument{40, 20, 72, {sky}}, PSDFixture::colorImage(40, 20, 0, 0, 0, 0)));
psd.close();
DNGFixture::write(app.path("Gray.dng"), {});
QList<QUrl> urls;
for (const char *name : {"Scene.jpg", "Scene.png", "Scene.tif", "Red.heic", "Mark.svg", "Trip.psd", "Gray.dng"})
urls << QUrl::fromLocalFile(app.path(QString::fromLatin1(name)));
bool done = false;
app.session().importImages(urls, std::nullopt, [&done] { done = true; });
// A camera RAW file asks how to develop it.
QTRY_VERIFY_WITH_TIMEOUT(app.session().rawDevelop(), 30000);
app.shot(QStringLiteral("12-raw-develop"));
app.session().finishRawDevelop(app.session().rawDevelop().value().settings);
QTRY_VERIFY_WITH_TIMEOUT(done, 30000);
QVERIFY(!app.session().importError());
QStringList names;
for (const ImageLayer &layer : app.session().document().value().layers)
names << layer.name;
// A layered Photoshop file arrives as a folder of layers.
QCOMPARE(names, (QStringList{"Scene", "Scene", "Scene", "Red", "Mark", "Trip", "Sky", "Gray"}));
const std::vector<ImageLayer> &layers = app.session().document().value().layers;
QVERIFY(layers[5].isGroup);
QCOMPARE(layers[6].parentID, std::optional<QUuid>(layers[5].id));
app.shot(QStringLiteral("13-imports"));
}
void AcceptanceProjectTests::projectsRecentScrubAndReload()
{
App app;
photo(app);
ProjectController &controller = app.workspace.current().controller;
const QString project = app.path("Trip.comp");
DialogDesk desk;
desk.replies = {project};
bool saved = false;
controller.save(true, [&saved](bool ok) { saved = ok; });
QTRY_VERIFY_WITH_TIMEOUT(saved, 30000);
// A second save runs while editing goes on.
saved = false;
app.action("newBlankLayer").trigger();
controller.save(false, [&saved](bool ok) { saved = ok; });
app.action("newBlankLayer").trigger();
QCOMPARE(int(app.session().document().value().layers.size()), 3);
QTRY_VERIFY_WITH_TIMEOUT(saved, 30000);
QCOMPARE(int(ProjectStore::load(project).manifest.layers.size()), 2);
// Open Recent lists the project.
const QMenu *recent = app.action("openRecent").menu();
QVERIFY(recent);
QCOMPARE(recent->actions().first()->text(), QStringLiteral("Trip"));
// Scrubbing the opacity title drags the value.
app.session().selectLayer(app.session().document().value().layers.front().id);
QLabel *opacity = nullptr;
for (QLabel *label : app.window.findChildren<QLabel *>())
if (label->text() == QStringLiteral("Opacity") && label->cursor().shape() == Qt::SizeHorCursor)
opacity = label;
QVERIFY(opacity);
QTest::mousePress(opacity, Qt::LeftButton, Qt::NoModifier, QPoint(2, 2));
QMouseEvent move(QEvent::MouseMove, QPointF(-38, 2), opacity->mapToGlobal(QPointF(-38, 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(opacity, &move);
QTest::mouseRelease(opacity, Qt::LeftButton, Qt::NoModifier, QPoint(-38, 2));
QVERIFY2(app.session().activeLayer().value().opacity < 0.9, qPrintable(QString::number(app.session().activeLayer().value().opacity)));
app.shot(QStringLiteral("14-scrub"));
// Another program rewrites the project; the app asks, then reverts.
ProjectSnapshot outside = ProjectStore::load(project);
outside.manifest.layers.front().name = QStringLiteral("Changed Outside");
QVERIFY(app.session().isModified());
const qsizetype asked = desk.seen.size();
desk.replies = {QStringLiteral("Revert")};
ProjectStore::save(outside, project);
QTRY_VERIFY_WITH_TIMEOUT(app.session().document().value().layers.front().name == QStringLiteral("Changed Outside"), 30000);
QCOMPARE(desk.seen.size(), asked + 1);
QCOMPARE(desk.seen.last(), QString("alert|2|“Trip.comp” was changed on disk.|Another app changed this project. You can revert to the version on "
"disk, losing your unsaved changes, or keep what you have.|Revert,Keep Mine"));
QVERIFY(desk.replies.isEmpty() && !app.session().isModified());
QCOMPARE(int(app.session().document().value().layers.size()), 2);
// The shortcuts window lists what the menus bind.
app.action("keyboardShortcuts").trigger();
app.shot(QStringLiteral("15-shortcuts"));
}
void AcceptanceProjectTests::omarchyTheme()
{
App app;
photo(app);
QCOMPARE(app.window.palette().color(QPalette::Base), QColor("#1b1b1b"));
// Omarchy's Catppuccin Latte, switched in while the app runs.
QVERIFY(QDir().mkpath(app.path("omarchy/theme")));
QFile colors(app.path("omarchy/theme/colors.toml"));
QVERIFY(colors.open(QIODevice::WriteOnly));
colors.write("mode = \"light\"\naccent = \"#1e66f5\"\nselection = \"#ccd0da\"\nmuted = \"#acb0be\"\nbackground = \"#eff1f5\"\n"
"dark_background = \"#e3e4e8\"\ndarker_background = \"#d7d8dc\"\nlighter_background = \"#dce0e8\"\nforeground = \"#4c4f69\"\n"
"dark_foreground = \"#9ca0b0\"\nlight_foreground = \"#5c5f77\"\nbright_foreground = \"#4c4f69\"\nred = \"#d20f39\"\n"
"yellow = \"#df8e1d\"\norange = \"#d84e2b\"\ngreen = \"#40a02b\"\n");
colors.close();
QTRY_COMPARE_WITH_TIMEOUT(app.window.palette().color(QPalette::Window), QColor("#eff1f5"), 10000);
QCOMPARE(app.window.palette().color(QPalette::Highlight), QColor("#1e66f5"));
app.shot(QStringLiteral("16-omarchy-light"));
}
QTEST_MAIN(AcceptanceProjectTests)
#include "AcceptanceProjectTests.moc"
+175
View File
@@ -0,0 +1,175 @@
#include "AcceptanceFixtures.h"
#include "Document/ColorPalette.h"
#include "Document/Guides.h"
#include "Document/ShapeTool.h"
// The acceptance run, part one: layers, selections, painting, arranging.
class AcceptanceTests : public QObject {
Q_OBJECT
private slots:
void initTestCase() { QStandardPaths::setTestModeEnabled(true); }
void layersFoldersMasksAndClipping();
void selections();
void paintingTools();
void transformGuidesAndGrid();
};
void AcceptanceTests::layersFoldersMasksAndClipping()
{
App app;
QVERIFY(scene(1200, 800).save(app.path("photo.png")));
app.session().createDocument(1200, 800);
app.importFile(app.path("photo.png"));
const QUuid photo = app.session().activeLayerID().value();
// A painted layer, clipped to the photo, multiplied at 60%.
app.action("newBlankLayer").trigger();
const QUuid paint = app.session().activeLayerID().value();
QVERIFY(paint != photo);
app.session().selectTool(NavigationTool::brush);
app.session().setBrushSettings({.diameter = 120});
app.session().setForegroundColor({1, 0.8, 0});
app.drag(QPointF(100, 400), QPointF(1100, 400));
app.settle();
QCOMPARE(pixel(app.session(), paint, 600, 400), QColor(255, 204, 0));
QCOMPARE(pixel(app.session(), paint, 600, 100).alpha(), 0);
app.action("clippingMask").trigger();
app.session().setLayerBlendMode(LayerBlendMode::multiply);
app.session().setLayerOpacity(0.6);
// A mask on the photo; both wrapped in a folder.
app.session().selectLayer(photo);
app.session().addLayerMask();
app.session().selectLayers({photo, paint}, paint);
app.action("groupLayers").trigger();
const std::vector<ImageLayer> &layers = app.session().document().value().layers;
QCOMPARE(int(layers.size()), 3);
const auto find = [&layers](const std::function<bool(const ImageLayer &)> &match) {
const auto found = std::find_if(layers.begin(), layers.end(), match);
if (found == layers.end())
throw std::runtime_error("no such layer");
return *found;
};
const ImageLayer folder = find([](const ImageLayer &layer) { return layer.isGroup; });
const ImageLayer photoLayer = find([photo](const ImageLayer &layer) { return layer.id == photo; });
const ImageLayer paintLayer = find([paint](const ImageLayer &layer) { return layer.id == paint; });
QVERIFY(photoLayer.mask);
QCOMPARE(photoLayer.parentID, std::optional<QUuid>(folder.id));
QCOMPARE(paintLayer.parentID, std::optional<QUuid>(folder.id));
QCOMPARE(paintLayer.maskSourceID, std::optional<QUuid>(photo));
QCOMPARE(paintLayer.blendMode, LayerBlendMode::multiply);
QCOMPARE(paintLayer.opacity, 0.6);
app.shot(QStringLiteral("01-layers"));
// Undo walks the steps back one at a time.
app.action("undo").trigger();
QCOMPARE(int(app.session().document().value().layers.size()), 2);
}
void AcceptanceTests::selections()
{
App app;
QVERIFY(scene(1200, 800).save(app.path("photo.png")));
app.session().createDocument(1200, 800);
app.importFile(app.path("photo.png"));
// A marquee dragged over the canvas.
app.session().selectTool(NavigationTool::marquee);
app.drag(QPointF(100, 100), QPointF(500, 300));
const QRectF marquee = app.session().selection().value().path.boundingRect();
QVERIFY2(qAbs(marquee.left() - 100) <= 2 && qAbs(marquee.right() - 500) <= 2, qPrintable(QString::number(marquee.width())));
// The wand takes the ground's flat green.
app.session().selectTool(NavigationTool::wand);
app.press(QPointF(600, 750));
app.release(QPointF(600, 750));
app.settle();
const QRectF ground = app.session().selection().value().path.boundingRect();
QVERIFY2(ground.top() >= 598 && ground.top() <= 602 && ground.width() >= 1198, qPrintable(QString::number(ground.top())));
// Select Subject finds the red disc.
app.action("subject").trigger();
app.settle();
const QRectF subject = app.session().selection().value().path.boundingRect();
QVERIFY2(qAbs(subject.center().x() - 600) < 12 && qAbs(subject.width() - 400) < 40, qPrintable(QString::number(subject.width())));
app.shot(QStringLiteral("02-selections"));
app.action("deselect").trigger();
QVERIFY(!app.session().selection());
}
void AcceptanceTests::paintingTools()
{
App app;
app.session().createDocument(1200, 800, true);
const QUuid base = app.session().activeLayerID().value();
// A gradient dragged across the empty layer.
app.session().selectTool(NavigationTool::gradient);
app.session().setForegroundColor({0.1, 0.2, 0.6});
app.drag(QPointF(0, 400), QPointF(1200, 400));
QTest::keyClick(&app.canvas(), Qt::Key_Return);
app.settle();
QCOMPARE(pixel(app.session(), base, 2, 400).blue(), 153);
// The default style fades the foreground to clear.
QVERIFY(qAbs(pixel(app.session(), base, 600, 400).alpha() - 128) <= 2);
// A rectangle, then a brush stroke on the base.
app.session().selectTool(NavigationTool::shape);
app.session().setShapeKind(ShapeKind::rectangle);
app.session().setForegroundColor({0.9, 0.3, 0.1});
app.drag(QPointF(200, 200), QPointF(500, 500));
const QUuid shape = app.session().activeLayerID().value();
QVERIFY(shape != base);
QVERIFY(app.session().activeLayer().value().liveShape());
app.session().selectLayer(base);
app.session().selectTool(NavigationTool::brush);
app.session().setBrushSettings({.diameter = 60, .hardness = 0.5});
app.session().setForegroundColor(PaletteColor::white());
app.drag(QPointF(700, 200), QPointF(1000, 600));
app.settle();
QCOMPARE(pixel(app.session(), base, 850, 400), QColor(Qt::white));
// Text typed on the canvas, its first word coloured.
app.session().selectTool(NavigationTool::type);
app.session().setForegroundColor(PaletteColor::black());
app.press(QPointF(600, 650));
app.release(QPointF(600, 650));
QTRY_VERIFY(app.session().textDraft());
QTest::keyClicks(&app.canvas(), QStringLiteral("Hello OmaPhoto"));
QCOMPARE(app.session().textDraft().value().style.content, QStringLiteral("Hello OmaPhoto"));
QTest::keyClick(&app.canvas(), Qt::Key_Home);
for (int letter = 0; letter < 5; ++letter)
QTest::keyClick(&app.canvas(), Qt::Key_Right, Qt::ShiftModifier);
app.session().setPaletteColor({0.9, 0.1, 0.1}, false);
app.shot(QStringLiteral("03-painting"));
QTest::keyClick(&app.canvas(), Qt::Key_Return, Qt::ControlModifier);
QTRY_VERIFY(!app.session().textDraft());
const LayerText text = app.session().activeLayer().value().liveText().value();
QCOMPARE(text.style.content, QStringLiteral("Hello OmaPhoto"));
// One run: the first word red over black text.
QCOMPARE(text.style.colorRuns.value(), (std::vector<LayerTextColorRun>{{.location = 0, .length = 5, .red = 0.9, .green = 0.1, .blue = 0.1}}));
QCOMPARE(int(app.session().document().value().layers.size()), 3);
}
void AcceptanceTests::transformGuidesAndGrid()
{
App app;
QVERIFY(scene(1200, 800).save(app.path("photo.png")));
app.session().createDocument(1600, 1000);
app.importFile(app.path("photo.png"));
// The Move tool drags the layer; Transform scales it.
app.session().selectTool(NavigationTool::move);
const QPointF origin = app.session().activeLayer().value().transform.origin;
app.drag(QPointF(800, 500), QPointF(900, 560));
// View points round to whole pixels on a reduced canvas.
const QPointF moved = app.session().activeLayer().value().transform.origin - origin;
QVERIFY2(qAbs(moved.x() - 100) <= 3 && qAbs(moved.y() - 60) <= 3, qPrintable(QString::number(moved.x())));
app.action("transformLayer").trigger();
QVERIFY(app.session().transformEdit());
LayerTransform half = app.session().activeLayer().value().transform;
half.size = half.size / 2;
app.session().previewTransform(half);
QTest::keyClick(&app.canvas(), Qt::Key_Return);
QCOMPARE(app.session().activeLayer().value().transform.size, QSizeF(600, 400));
// Rulers, a guide and the grid.
app.session().setShowsRulers(true);
app.session().setShowsGrid(true);
app.session().addGuide({.id = QUuid::createUuid(), .axis = CanvasGuide::Axis::horizontal, .position = 500});
QCOMPARE(int(app.session().document().value().guides.size()), 1);
app.session().selectTool(NavigationTool::move);
app.shot(QStringLiteral("04-transform-guides-grid"));
}
QTEST_MAIN(AcceptanceTests)
#include "AcceptanceTests.moc"
+12
View File
@@ -42,6 +42,7 @@ class KeyboardShortcutsSheetTests : public QObject {
Q_OBJECT
private slots:
void initTestCase();
void theWindowGoesWhileThePanelHoldsTheKeys();
void cleanup();
void aRecordedKeyRemapsItsMenuEntryOnSave();
void recordingWaitsForARealKey();
@@ -182,5 +183,16 @@ void KeyboardShortcutsSheetTests::cancelKeepsWhatWasSaved()
QCOMPARE(ShortcutSettings::shared().overrides().size(), 1);
}
// Hiding the panel moves the focus while the menus go.
void KeyboardShortcutsSheetTests::theWindowGoesWhileThePanelHoldsTheKeys()
{
auto bar = std::make_unique<Bar>();
QWidget &panel = opened(*bar);
QVERIFY(QTest::qWaitForWindowActive(&panel));
QTRY_COMPARE(QApplication::activeWindow(), &panel);
bar.reset();
QVERIFY(!shownPanel());
}
QTEST_MAIN(KeyboardShortcutsSheetTests)
#include "KeyboardShortcutsSheetTests.moc"
+4
View File
@@ -176,6 +176,10 @@ compositor_test(TextEditingGatesTests)
compositor_test(DitherTests)
compositor_test(DitherSheetTests)
compositor_test(DitherKernelTests)
compositor_test(AcceptanceTests)
compositor_test(AcceptanceColorTests)
compositor_test(AcceptanceProjectTests)
compositor_test(WindowTeardownTests)
compositor_test(RecentProjectsCoreTests)
compositor_test(ProjectWorkspaceSignalTests)
compositor_test(ProjectWorkspaceTests)
+61
View File
@@ -0,0 +1,61 @@
#include "AcceptanceFixtures.h"
#include "UI/ColorPickerSheet.h"
// The window goes while a panel's field holds typing.
class WindowTeardownTests : public QObject {
Q_OBJECT
private slots:
void initTestCase() { QStandardPaths::setTestModeEnabled(true); }
void theWindowGoesWhileAPanelFieldHoldsTyping_data();
void theWindowGoesWhileAPanelFieldHoldsTyping();
};
namespace {
// The shown panel field to type into.
PickerField &field(const QString &name)
{
for (QWidget *window : QApplication::topLevelWidgets())
if (window->isVisible())
for (PickerField *found : window->findChildren<PickerField *>())
if (found->isVisible() && (name.isEmpty() || found->objectName() == name))
return *found;
throw std::runtime_error("no shown panel field");
}
}
void WindowTeardownTests::theWindowGoesWhileAPanelFieldHoldsTyping_data()
{
QTest::addColumn<QString>("entry");
QTest::addColumn<QString>("name");
QTest::newRow("colour picker") << QString() << QStringLiteral("r");
for (const char *entry : {"levels", "hueSaturation", "gaussianBlur"})
QTest::newRow(entry) << QString::fromLatin1(entry) << QString();
}
void WindowTeardownTests::theWindowGoesWhileAPanelFieldHoldsTyping()
{
QFETCH(QString, entry);
QFETCH(QString, name);
auto app = std::make_unique<App>();
QVERIFY(scene(120, 80).save(app->path("photo.png")));
app->session().createDocument(120, 80);
app->importFile(app->path("photo.png"));
if (entry.isEmpty())
app->session().openColorPicker(false);
else
app->action(entry.toLatin1().constData()).trigger();
QTRY_VERIFY(QApplication::activeWindow() != &app->window);
PickerField &typed = field(name);
typed.setFocus();
QTRY_VERIFY(typed.hasFocus());
typed.selectAll();
QTest::keyClicks(&typed, QStringLiteral("12"));
QVERIFY(typed.isModified());
// The panel commits as it hides, into a going window.
app.reset();
for (QWidget *window : QApplication::topLevelWidgets())
QVERIFY(!window->isVisible());
}
QTEST_MAIN(WindowTeardownTests)
#include "WindowTeardownTests.moc"