You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
libcutefish/io/tests/tst_thumbnailers.cpp

221 lines
8.9 KiB
C++

// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "thumbnailcache.h"
#include "thumbnailprovider.h"
#include <QCryptographicHash>
#include <QDir>
#include <QElapsedTimer>
#include <QImage>
#include <QProcess>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QtTest>
#include <unistd.h>
static void writeFile(const QString &path, const QByteArray &data)
{
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly));
QCOMPARE(file.write(data), data.size());
}
static QByteArray readFile(const QString &path)
{
QFile file(path);
return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
}
static QString localUrl(const QString &path)
{
return QUrl::fromLocalFile(path).toString();
}
class ThumbnailersTest : public QObject
{
Q_OBJECT
QTemporaryDir m_tools;
QTemporaryDir m_files;
QString m_thumbnailers;
QString m_cache;
void installThumbnailer(const QString &name, const QByteArray &body)
{
writeFile(m_thumbnailers + u'/' + name, "[Thumbnailer Entry]\n" + body);
}
private slots:
void initTestCase()
{
QStandardPaths::setTestModeEnabled(true);
QVERIFY(m_tools.isValid());
QVERIFY(m_files.isValid());
m_thumbnailers = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation)
+ QStringLiteral("/thumbnailers");
m_cache = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation)
+ QStringLiteral("/thumbnails");
QDir(m_thumbnailers).removeRecursively();
QDir(m_cache).removeRecursively();
QVERIFY(QDir().mkpath(m_thumbnailers));
QImage source(300, 150, QImage::Format_RGB32);
source.fill(Qt::red);
QVERIFY(source.save(m_tools.filePath("source.png")));
// Records its arguments and writes the source image to the last one.
const QString script = m_tools.filePath("thumbnail.sh");
writeFile(script, "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + m_tools.filePath("arguments").toUtf8()
+ "\nfor last; do :; done\ncp " + m_tools.filePath("source.png").toUtf8()
+ " \"$last\"\n");
QVERIFY(QFile::setPermissions(script, QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner));
installThumbnailer("record.thumbnailer",
"TryExec=" + script.toUtf8() + "\nExec=" + script.toUtf8()
+ " -s %s %u literal%% %o\nMimeType=text/plain;\n");
installThumbnailer("fail.thumbnailer", "Exec=false %i %o\nMimeType=text/csv;\n");
installThumbnailer("hang.thumbnailer", "Exec=sh -c \"sleep 63\" sh %i %o\nMimeType=text/x-csrc;\n");
installThumbnailer("missing.thumbnailer",
"TryExec=/nonexistent/thumbnailer\nExec=/nonexistent/thumbnailer %i %o\n"
"MimeType=application/json;\n");
}
void cleanupTestCase()
{
QDir(m_thumbnailers).removeRecursively();
QDir(m_cache).removeRecursively();
}
void runsInstalledThumbnailer()
{
QVERIFY(ThumbnailCache::self()->canThumbnail(QStringLiteral("text/plain")));
const QString note = m_files.filePath("a note.txt");
writeFile(note, "text");
const Thumbnail thumbnail = ThumbnailCache::self()->requestThumbnail(localUrl(note), QSize(64, 64));
// The normal flavour is 128 pixels; the larger output is scaled into it.
QCOMPARE(thumbnail.image.size(), QSize(128, 64));
QVERIFY(thumbnail.path.startsWith(m_cache + QStringLiteral("/normal/")));
QVERIFY(QFileInfo::exists(thumbnail.path));
const QStringList arguments =
QString::fromUtf8(readFile(m_tools.filePath("arguments"))).split(u'\n', Qt::SkipEmptyParts);
QCOMPARE(arguments.size(), 5);
QCOMPARE(arguments.at(0), QStringLiteral("-s"));
QCOMPARE(arguments.at(1), QStringLiteral("128"));
QCOMPARE(arguments.at(2), QString::fromLatin1(QUrl::fromLocalFile(note).toEncoded()));
QCOMPARE(arguments.at(3), QStringLiteral("literal%"));
QVERIFY(arguments.at(4).endsWith(QStringLiteral("/thumbnail.png")));
QVERIFY(!arguments.at(4).startsWith(m_cache));
// A second request reads the cache instead of running the thumbnailer again.
QFile::remove(m_tools.filePath("arguments"));
const Thumbnail cached = ThumbnailCache::self()->requestThumbnail(localUrl(note), QSize(64, 64));
QCOMPARE(cached.path, thumbnail.path);
QVERIFY(!QFileInfo::exists(m_tools.filePath("arguments")));
}
void imagesStayInProcess()
{
QVERIFY(ThumbnailCache::self()->canThumbnail(QStringLiteral("image/png")));
const QString image = m_files.filePath("image.png");
QImage source(40, 20, QImage::Format_RGB32);
source.fill(Qt::blue);
QVERIFY(source.save(image));
QFile::remove(m_tools.filePath("arguments"));
const Thumbnail thumbnail = ThumbnailCache::self()->requestThumbnail(localUrl(image), QSize(64, 64));
QCOMPARE(thumbnail.image.size(), QSize(128, 64));
QVERIFY(!QFileInfo::exists(m_tools.filePath("arguments")));
}
void cachedThumbnailsAreReadWithoutGenerating()
{
const QString image = m_files.filePath("served.png");
QImage source(40, 20, QImage::Format_RGB32);
source.fill(Qt::red);
QVERIFY(source.save(image));
const QUrl url(QStringLiteral("image://thumbnailcache/") + localUrl(image) + QStringLiteral("?mtime=1"));
const QString id = url.toString(QUrl::RemoveScheme | QUrl::RemoveAuthority).mid(1);
CachedThumbnailProvider provider;
QSize size;
QVERIFY(!ThumbnailProvider::isCached(url, QSize(96, 96)));
QVERIFY(provider.requestImage(id, &size, QSize(96, 96)).isNull());
QVERIFY(!ThumbnailCache::self()->requestThumbnail(localUrl(image), QSize(96, 96)).image.isNull());
QVERIFY(ThumbnailProvider::isCached(url, QSize(96, 96)));
QVERIFY(!ThumbnailProvider::isCached(url, QSize(200, 200)));
const QImage served = provider.requestImage(id, &size, QSize(120, 100));
QCOMPARE(size, QSize(128, 64));
QCOMPARE(served.pixelColor(0, 0), QColor(Qt::red));
// A changed file leaves a stale entry behind, which is not served.
QVERIFY(QFile(image).resize(QFileInfo(image).size() + 1));
QVERIFY(provider.requestImage(id, &size, QSize(96, 96)).isNull());
// Nor is one older than the file offered at all.
QFile file(image);
QVERIFY(file.open(QIODevice::ReadWrite));
QVERIFY(file.setFileTime(QDateTime::currentDateTime().addSecs(60), QFileDevice::FileModificationTime));
file.close();
QVERIFY(!ThumbnailProvider::isCached(url, QSize(96, 96)));
}
void failureIsRemembered()
{
QVERIFY(ThumbnailCache::self()->canThumbnail(QStringLiteral("text/csv")));
const QString table = m_files.filePath("table.csv");
writeFile(table, "a,b\n");
const Thumbnail thumbnail = ThumbnailCache::self()->requestThumbnail(localUrl(table), QSize(64, 64));
QVERIFY(thumbnail.image.isNull());
const QString name = QString::fromLatin1(
QCryptographicHash::hash(QUrl::fromLocalFile(table).toEncoded(), QCryptographicHash::Md5)
.toHex());
QVERIFY(QFileInfo::exists(m_cache + QStringLiteral("/fail/cutefish/") + name + QStringLiteral(".png")));
}
void hangingThumbnailerIsStopped()
{
const QString script = m_files.filePath("source.c");
writeFile(script, "int x;\n");
QElapsedTimer timer;
timer.start();
const Thumbnail thumbnail = ThumbnailCache::self()->requestThumbnail(localUrl(script), QSize(64, 64));
QVERIFY(thumbnail.image.isNull());
QVERIFY(timer.elapsed() >= 9000);
QVERIFY(timer.elapsed() < 15000);
// The child of the wrapper shell goes with it.
QTRY_COMPARE(QProcess::execute(QStringLiteral("pgrep"),
{QStringLiteral("-u"), QString::number(::getuid()),
QStringLiteral("-fx"), QStringLiteral("sleep 63")}),
1);
}
void skipsMissingPrograms()
{
QVERIFY(!ThumbnailCache::self()->canThumbnail(QStringLiteral("application/json")));
QVERIFY(!ThumbnailCache::self()->canThumbnail(QStringLiteral("application/x-cutefish-unknown")));
}
void picksUpNewThumbnailers()
{
QVERIFY(!ThumbnailCache::self()->canThumbnail(QStringLiteral("text/markdown")));
installThumbnailer("markdown.thumbnailer", "Exec=true %i %o\nMimeType=text/markdown;\n");
QTRY_VERIFY_WITH_TIMEOUT(ThumbnailCache::self()->canThumbnail(QStringLiteral("text/markdown")),
6000);
}
};
QTEST_GUILESS_MAIN(ThumbnailersTest)
#include "tst_thumbnailers.moc"