Files
OmaPhoto/tests/ImageFileDropTests.cpp
T
2026-10-11 12:14:13 -04:00

430 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "ContentView.h"
#include "IO/ImageFileDrop.h"
#include "DNGFixture.h"
#include "PSDFixture.h"
#include "UI/ProjectTabs.h"
#include "UI/ProjectWorkspaceView.h"
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QMessageBox>
#include <QTemporaryDir>
#include <QtTest>
#include <csignal>
#include <sys/resource.h>
// Swift's ImageFileDrop: files and pictures reach the importer in order.
class ImageFileDropTests : public QObject {
Q_OBJECT
private slots:
void fileDropProvidersReachImporterInOrder();
void aPictureIsCopiedToAFileFirst();
void whatCannotBeReadIsSaid();
void onlyFilesAndPicturesAreHeld();
void theCanvasTheTabsAndTheNewButtonTakeFiles();
void aCanvasAloneImportsUnderSwiftsGuards();
void anOpenAlertShowsTheGrownError();
void eachFileWaitsBehindTheErrorBeforeIt();
void aPictureThatCannotBeKeptIsUnreadable();
void aPictureReachesATabWhole();
void aLazyDragGivesOnlyThePictureChosen();
};
namespace {
QString fixture(const QTemporaryDir &folder, const QString &name, const char *format)
{
QImage image(6, 4, QImage::Format_RGBA8888);
image.fill(Qt::red);
const QString path = folder.filePath(name);
if (!image.save(path, format))
throw std::runtime_error("could not write a fixture");
return path;
}
QMimeData *files(const QStringList &paths, const QList<QUrl> &others = {})
{
auto *data = new QMimeData;
QList<QUrl> urls;
for (const QString &path : paths)
urls << QUrl::fromLocalFile(path);
data->setUrls(urls + others);
return data;
}
// Each dropped picture's copy, in a temporary folder.
QStringList droppedCopies()
{
QStringList found;
for (const QString &folder : QDir::temp().entryList(QDir::Dirs | QDir::NoDotAndDotDot))
for (const QString &file : QDir(QDir::temp().filePath(folder)).entryList({"Dropped.*"}, QDir::Files))
found << QDir::temp().filePath(folder + "/" + file);
return found;
}
// The copy one drop made, taken away again.
QString takeCopy(const QStringList &before)
{
QStringList made = droppedCopies();
for (const QString &old : before)
made.removeAll(old);
if (made.size() != 1)
throw std::runtime_error("a drop made no single copy");
QDir(QFileInfo(made.front()).path()).removeRecursively();
return QFileInfo(made.front()).fileName();
}
QStringList names(const EditorSession &session)
{
QStringList listed;
if (session.document())
for (const ImageLayer &layer : session.document().value().layers)
listed << layer.name;
return listed;
}
bool dropOn(QWidget &target, QMimeData *data, QPoint at, Qt::KeyboardModifiers modifiers = Qt::NoModifier)
{
QDragEnterEvent entered(at, Qt::CopyAction, data, Qt::LeftButton, modifiers);
QApplication::sendEvent(&target, &entered);
QDropEvent dropped(at, Qt::CopyAction, data, Qt::LeftButton, modifiers);
QApplication::sendEvent(&target, &dropped);
delete data;
return entered.isAccepted() && dropped.isAccepted();
}
}
void ImageFileDropTests::fileDropProvidersReachImporterInOrder()
{
QTemporaryDir folder;
const QString first = fixture(folder, "first.png", "PNG"), second = fixture(folder, "second.jpg", "JPEG");
EditorSession session;
bool done = false;
std::unique_ptr<QMimeData> data(files({first, second}));
ImageFileDrop::importProviders(*data, session, std::nullopt, nullptr, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(names(session), (QStringList{"first", "second"}));
QVERIFY(!session.importError());
}
void ImageFileDropTests::aPictureIsCopiedToAFileFirst()
{
QImage image(3, 5, QImage::Format_RGBA8888);
image.fill(Qt::blue);
QByteArray png;
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
// A browser's picture: a web address and the bytes.
QMimeData data;
data.setUrls({QUrl("https://example.com/picture.png")});
data.setData("image/jpeg", "not read: PNG comes first");
data.setData("image/png", png);
EditorSession session;
bool done = false;
const QStringList before = droppedCopies();
ImageFileDrop::importProviders(data, session, QPointF(2, 2), nullptr, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!session.importError());
// The copy keeps the layer's name, in its own folder.
QCOMPARE(names(session), QStringList{"Dropped"});
QCOMPARE(session.document().value().size(), QSize(3, 5));
QCOMPARE(takeCopy(before), QString("Dropped.png"));
// A Photoshop picture comes after TIFF and opens.
PSDRecord sky;
sky.id = QUuid::createUuid();
sky.name = "Sky";
sky.image = PSDFixture::colorImage(4, 2, 0, 0, 1);
sky.bounds = QRectF(0, 0, 4, 2);
QMimeData photoshop;
photoshop.setData("image/vnd.adobe.photoshop", PSDFixture::data(PSDDocument{4, 2, 72, {sky}}, sky.image.value()));
photoshop.setData("application/x-other", "ignored");
QCOMPARE(ImageFileDrop::providers(photoshop).front()->formats(), QStringList{"image/vnd.adobe.photoshop"});
photoshop.setData("image/tiff", "tiff first");
QCOMPARE(ImageFileDrop::providers(photoshop).front()->formats(), QStringList{"image/tiff"});
photoshop.removeFormat("image/tiff");
EditorSession opened;
done = false;
ImageFileDrop::importProviders(photoshop, opened, std::nullopt, nullptr, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!opened.importError());
QCOMPARE(names(opened), QStringList{"Sky"});
QCOMPARE(opened.history.undoName(), QString("Import Photoshop File"));
QCOMPARE(takeCopy(before), QString("Dropped.psd"));
// A camera RAW comes after Photoshop, before any other picture.
QMimeData raw;
raw.setData("image/gif", "any picture last");
raw.setData("image/x-adobe-dng", DNGFixture::data({}));
QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/x-adobe-dng"});
// A Large Document: after a PSD, before RAW and others.
raw.setData("image/x-photoshop-large-image", "large first");
QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/x-photoshop-large-image"});
raw.setData("image/vnd.adobe.photoshop", "photoshop first");
QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/vnd.adobe.photoshop"});
raw.removeFormat("image/vnd.adobe.photoshop");
raw.removeFormat("image/x-photoshop-large-image");
EditorSession developed;
done = false;
ImageFileDrop::importProviders(raw, developed, std::nullopt, nullptr, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(developed.rawDevelop());
developed.finishRawDevelop(developed.rawDevelop().value().settings);
QTRY_VERIFY(done);
QCOMPARE(names(developed), QStringList{"Dropped"});
QCOMPARE(takeCopy(before), QString("Dropped.dng"));
}
void ImageFileDropTests::whatCannotBeReadIsSaid()
{
const QString said = "Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, TIFF, WebP, or Photoshop (PSD) files from the file manager.";
EditorSession session;
bool done = false;
std::unique_ptr<QMimeData> link(files({}, {QUrl("https://example.com/page")}));
ImageFileDrop::importProviders(*link, session, std::nullopt, nullptr, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(session.importError(), std::optional(said));
QVERIFY(!session.document());
// A file among them arrives; the words join the importer's.
QTemporaryDir folder;
const QString broken = folder.filePath("broken.png");
QFile file(broken);
QVERIFY(file.open(QIODevice::WriteOnly) && file.write("no picture") > 0);
file.close();
session.setImportError(std::nullopt);
done = false;
std::unique_ptr<QMimeData> mixed(files({fixture(folder, "kept.png", "PNG"), broken}, {QUrl("https://example.com/page")}));
ImageFileDrop::importProviders(*mixed, session, std::nullopt, nullptr, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(names(session), QStringList{"kept"});
const QString error = session.importError().value();
QVERIFY(error.startsWith("broken.png") && error.endsWith("\n\n" + said));
}
void ImageFileDropTests::onlyFilesAndPicturesAreHeld()
{
QMimeData data;
QVERIFY(!ImageFileDrop::holdsImages(data));
data.setText("words");
data.setUrls({QUrl("https://example.com/picture.png")});
QVERIFY(!ImageFileDrop::holdsImages(data));
data.setData("image/webp", "bytes");
QVERIFY(ImageFileDrop::holdsImages(data));
QMimeData file;
file.setUrls({QUrl::fromLocalFile("/tmp/any.png")});
QVERIFY(ImageFileDrop::holdsImages(file));
}
void ImageFileDropTests::theCanvasTheTabsAndTheNewButtonTakeFiles()
{
QTemporaryDir folder;
const QString path = fixture(folder, "dropped.png", "PNG");
ProjectWorkspace workspace;
ProjectWorkspaceView window{workspace};
ProjectTab &first = workspace.current();
first.session.createDocument(40, 20);
ProjectTab &second = workspace.addTab(false);
second.session.createDocument(8, 8);
workspace.select(first.id);
window.show();
QVERIFY(QTest::qWaitForWindowActive(&window));
auto *content = window.findChild<ContentView *>();
QVERIFY(content && dropOn(*content, files({path}), content->rect().center()));
QTRY_COMPARE(names(first.session), QStringList{"dropped"});
// Words are no files.
auto *words = new QMimeData;
words->setText("dropped.png");
QVERIFY(!dropOn(*content, words, content->rect().center()));
// Alt refuses only layer rows; files go where dropped.
ProjectTabButton *secondButton = nullptr;
for (ProjectTabButton *button : window.tabs()->buttons())
if (button->tab->id == second.id)
secondButton = button;
QTest::keyPress(window.windowHandle(), Qt::Key_Alt, Qt::AltModifier);
QVERIFY(secondButton && dropOn(*secondButton, files({path}), QPoint(5, 5), Qt::AltModifier));
QTest::keyRelease(window.windowHandle(), Qt::Key_Alt, Qt::NoModifier);
QTRY_COMPARE(names(second.session), QStringList{"dropped"});
// The last import ends before the next drop is taken.
QTRY_VERIFY(workspace.canSwitch());
auto *newButton = window.findChild<NewCanvasButton *>();
QVERIFY(newButton && dropOn(*newButton, files({path}), QPoint(5, 5)));
QTRY_COMPARE(int(workspace.tabs().size()), 3);
QTRY_COMPARE(names(workspace.current().session), QStringList{"dropped"});
}
// Without a workspace, drops go to the canvas's session.
void ImageFileDropTests::aCanvasAloneImportsUnderSwiftsGuards()
{
QTemporaryDir folder;
const QString path = fixture(folder, "alone.png", "PNG");
EditorSession session;
session.createDocument(40, 20);
QImage red(10, 10, QImage::Format_RGBA8888_Premultiplied);
red.fill(Qt::red);
session.insert(ImportedImage(red, QImage(), "Red"), QPointF(20, 10));
ContentView content(session);
content.resize(1000, 700);
content.show();
const auto refused = [&] { return !dropOn(content, files({path}), content.rect().center()); };
session.setIsProjectBusy(true);
QVERIFY(refused());
session.setIsProjectBusy(false);
session.setShowsNewDocument(true);
QVERIFY(refused());
session.setShowsNewDocument(false);
session.setShowsImporter(true);
QVERIFY(refused());
session.setShowsImporter(false);
session.setRenamingLayerID(session.activeLayerID());
QVERIFY(refused());
session.setRenamingLayerID(std::nullopt);
session.beginLevels();
QVERIFY(session.levels() && refused());
session.cancelLevels();
QVERIFY(dropOn(content, files({path}), content.rect().center()));
QTRY_COMPARE(names(session), (QStringList{"Red", "alone"}));
}
// Words added to a shown error reach the shown alert.
void ImageFileDropTests::anOpenAlertShowsTheGrownError()
{
EditorSession session;
ContentView content(session);
content.show();
session.setImportError(QStringLiteral("broken.png could not be read."));
auto *alert = content.findChild<QMessageBox *>();
QVERIFY(alert && alert->isVisible());
session.setImportError(QStringLiteral("broken.png could not be read.\n\nSome dropped items couldn’t be read."));
QCOMPARE(content.findChildren<QMessageBox *>().size(), 1);
QCOMPARE(alert->informativeText(), QString("broken.png could not be read.\n\nSome dropped items couldn’t be read."));
}
// Swift's gate between providers: an error holds the next.
void ImageFileDropTests::eachFileWaitsBehindTheErrorBeforeIt()
{
QTemporaryDir folder;
for (const char *name : {"first.png", "second.png"}) {
QFile broken(folder.filePath(name));
QVERIFY(broken.open(QIODevice::WriteOnly) && broken.write("no picture") > 0);
}
ProjectWorkspace workspace;
EditorSession &session = workspace.current().session;
session.createDocument(8, 8);
bool done = false;
std::unique_ptr<QMimeData> data(files({folder.filePath("first.png"), folder.filePath("second.png")}));
workspace.receiveProviders(*data, workspace.current().id, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(session.importError().has_value());
QVERIFY(session.importError().value().startsWith("first.png"));
// The second waits while the first error stands.
QTest::qWait(150);
QVERIFY(!done && !session.importError().value().contains("second.png"));
session.setImportError(std::nullopt);
QTRY_VERIFY(done);
QVERIFY(session.importError().value().startsWith("second.png"));
}
// A copy cut short by a size limit imports nothing.
void ImageFileDropTests::aPictureThatCannotBeKeptIsUnreadable()
{
QImage noisy(64, 64, QImage::Format_RGBA8888);
quint32 seed = 7;
for (int y = 0; y < 64; ++y)
for (int x = 0; x < 64; ++x)
noisy.setPixel(x, y, (seed = seed * 1664525u + 1013904223u) | 0xff000000u);
QByteArray png;
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
noisy.save(&buffer, "PNG");
QVERIFY(png.size() > 4096);
QMimeData data;
data.setData("image/png", png);
// No copy stays behind, and no folder named for one.
const auto dropped = [] {
QStringList kept = droppedCopies();
for (const QString &folder : QDir::temp().entryList(QDir::Dirs | QDir::NoDotAndDotDot))
if (!QUuid::fromString(folder).isNull())
kept << folder;
return kept;
};
const QStringList before = dropped();
EditorSession session;
bool done = false;
signal(SIGXFSZ, SIG_IGN);
rlimit room{};
QCOMPARE(getrlimit(RLIMIT_FSIZE, &room), 0);
rlimit tight = room;
tight.rlim_cur = 1024;
QCOMPARE(setrlimit(RLIMIT_FSIZE, &tight), 0);
ImageFileDrop::importProviders(data, session, std::nullopt, nullptr, std::nullopt, [&done] { done = true; });
QCOMPARE(setrlimit(RLIMIT_FSIZE, &room), 0);
QTRY_VERIFY(done);
QCOMPARE(session.importError(), std::optional<QString>("Some dropped items couldn’t be read. Drag JPEG, PNG, HEIC, TIFF, WebP, or Photoshop (PSD) files from the file manager."));
QVERIFY(!session.document());
QCOMPARE(dropped(), before);
}
// A picture with its address is one provider, whole.
void ImageFileDropTests::aPictureReachesATabWhole()
{
QImage image(5, 3, QImage::Format_RGBA8888);
image.fill(Qt::green);
QByteArray png;
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
QMimeData data;
data.setUrls({QUrl("https://example.com/picture.png")});
data.setData("image/png", png);
ProjectWorkspace workspace;
EditorSession &session = workspace.current().session;
session.createDocument(8, 8);
bool done = false;
const QStringList before = droppedCopies();
workspace.receiveProviders(data, workspace.current().id, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!session.importError());
QCOMPARE(names(session), QStringList{"Dropped"});
QCOMPARE(takeCopy(before), QString("Dropped.png"));
}
namespace {
// A drag that hands each format over only when asked.
class LazyDrag : public QMimeData {
public:
QByteArray png;
mutable QStringList asked;
QStringList formats() const override { return {"text/uri-list", "image/bmp", "image/png", "image/jpeg"}; }
protected:
QVariant retrieveData(const QString &type, QMetaType) const override
{
asked << type;
if (type == QLatin1String("text/uri-list"))
return QVariantList{QUrl("https://example.com/picture.png")};
return type == QLatin1String("image/png") ? QVariant(png) : QVariant();
}
};
}
void ImageFileDropTests::aLazyDragGivesOnlyThePictureChosen()
{
QImage image(4, 6, QImage::Format_RGBA8888);
image.fill(Qt::blue);
LazyDrag drag;
QBuffer buffer(&drag.png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
ProjectWorkspace workspace;
EditorSession &session = workspace.current().session;
session.createDocument(8, 8);
bool done = false;
const QStringList before = droppedCopies();
workspace.receiveProviders(drag, workspace.current().id, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!session.importError());
QCOMPARE(names(session), QStringList{"Dropped"});
QCOMPARE(takeCopy(before), QString("Dropped.png"));
drag.asked.removeDuplicates();
drag.asked.sort();
QCOMPARE(drag.asked, (QStringList{"image/png", "text/uri-list"}));
}
QTEST_MAIN(ImageFileDropTests)
#include "ImageFileDropTests.moc"