Acceptance run with screenshots and the MVP table; window teardown no longer reaches destroyed menus or toolbar (11.5)
This commit is contained in:
1 parent
51bec23cc5
commit
e94fd73573
30 files changed
+791
-3
No files matched your search
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user