200 lines
12 KiB
C++
200 lines
12 KiB
C++
#include "app/controller.h"
|
|
#include "app/pdf_canvas.h"
|
|
#include "web/web_profile.h"
|
|
#include <QCryptographicHash>
|
|
#include <QElapsedTimer>
|
|
#include <QFile>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QPointer>
|
|
#include <QQmlApplicationEngine>
|
|
#include <QQmlContext>
|
|
#include <QSaveFile>
|
|
#include <QScreen>
|
|
#include <QScopeGuard>
|
|
#include <QTemporaryDir>
|
|
#include <QtTest>
|
|
#include <QtWebEngineQuick>
|
|
using namespace docview;
|
|
|
|
static QByteArray digest(const QString &path) {
|
|
QFile file(path); if (!file.open(QIODevice::ReadOnly)) return {};
|
|
QCryptographicHash hash(QCryptographicHash::Sha256);
|
|
return hash.addData(&file) ? hash.result() : QByteArray{};
|
|
}
|
|
|
|
class HeavyPdfTest : public QObject {
|
|
Q_OBJECT
|
|
private slots:
|
|
void realHeavyPagesRemainResponsiveAndCancel() {
|
|
const auto heavy = qEnvironmentVariable("DOCVIEW_HEAVY_PDF");
|
|
const auto light = qEnvironmentVariable("DOCVIEW_LIGHT_PDF");
|
|
const auto output = qEnvironmentVariable("DOCVIEW_HEAVY_OUTPUT");
|
|
QVERIFY(!heavy.isEmpty() && !light.isEmpty() && !output.isEmpty());
|
|
const auto beforeHeavy = digest(heavy), beforeLight = digest(light);
|
|
QVERIFY(!beforeHeavy.isEmpty() && !beforeLight.isEmpty());
|
|
QTemporaryDir isolated; QVERIFY(isolated.isValid());
|
|
const auto config = isolated.filePath("config.toml");
|
|
QFile configuration(config); QVERIFY(configuration.open(QIODevice::WriteOnly));
|
|
configuration.write("schema_version=1\n[privacy]\nremember_position=false\nrecent_documents=0\n[performance]\nprefetch_pages=0\n");
|
|
configuration.close();
|
|
Controller reader;
|
|
reader.initialize(config, false, {config, isolated.filePath("state.json"),
|
|
isolated.filePath("cache"), isolated.filePath("logs")});
|
|
QQmlApplicationEngine engine;
|
|
engine.rootContext()->setContextProperty("reader", &reader);
|
|
engine.load(QUrl("qrc:/qml/Main.qml"));
|
|
QVERIFY(!engine.rootObjects().isEmpty());
|
|
auto *window = qobject_cast<QQuickWindow *>(engine.rootObjects().first());
|
|
QVERIFY(window); reader.attachWindow(window);
|
|
window->resize(qMin(1100, window->screen()->availableGeometry().width()),
|
|
qMin(760, window->screen()->availableGeometry().height()));
|
|
auto *canvas = window->findChild<PdfCanvas *>(); QVERIFY(canvas);
|
|
QVERIFY(QTest::qWaitForWindowExposed(window, 10000)); window->requestActivate();
|
|
QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window, 5000);
|
|
QElapsedTimer clock; clock.start(); qint64 previous = 0, longestGap = 0; int ticks = 0, peakPressure = 0;
|
|
QTimer heartbeat;
|
|
connect(&heartbeat, &QTimer::timeout, this, [&] {
|
|
const auto now = clock.elapsed(); longestGap = qMax(longestGap, now - previous);
|
|
peakPressure = qMax(peakPressure, canvas->memoryPressureLevel());
|
|
previous = now; ++ticks;
|
|
});
|
|
heartbeat.start(10);
|
|
QJsonArray phases; bool success = false;
|
|
const auto save = qScopeGuard([&] {
|
|
heartbeat.stop();
|
|
QJsonObject result{{"success", success}, {"phases", phases},
|
|
{"elapsedMs", clock.elapsed()}, {"heartbeatTicks", ticks}, {"longestHeartbeatGapMs", longestGap},
|
|
{"maximumMemoryPressureLevel", peakPressure},
|
|
{"qtPlatform", QGuiApplication::platformName()}, {"qtVersion", qVersion()},
|
|
{"quickBackend", qEnvironmentVariable("QT_QUICK_BACKEND")},
|
|
{"windowWidth", window->width()}, {"windowHeight", window->height()},
|
|
{"devicePixelRatio", window->devicePixelRatio()},
|
|
{"heavySha256", QString::fromLatin1(beforeHeavy.toHex())},
|
|
{"lightSha256", QString::fromLatin1(beforeLight.toHex())},
|
|
{"heavyUnchanged", digest(heavy) == beforeHeavy}, {"lightUnchanged", digest(light) == beforeLight},
|
|
{"scope", "Production Controller/QML/Canvas with canonical sandboxed workers; GUI timer gaps and dispatched RPCs, not physical scanout or direct instrumentation inside PDFium"}};
|
|
QSaveFile file(output + "/measurement.json");
|
|
if (file.open(QIODevice::WriteOnly)) { file.write(QJsonDocument(result).toJson()); file.commit(); }
|
|
});
|
|
const auto ready = [&] { return reader.state() == "Ready" && canvas->viewportReady(); };
|
|
const auto sessionFor = [&](const QString &path) -> Session * {
|
|
for (auto *child : reader.children()) {
|
|
auto *session = dynamic_cast<Session *>(child);
|
|
if (session && session->source == path) return session;
|
|
}
|
|
return nullptr;
|
|
};
|
|
const auto sampleMarker = [&](int page) {
|
|
const QImage capture = window->grabWindow();
|
|
const QPointF logical = canvas->mapToScene(canvas->pageToCanvas(page, {46, 796}));
|
|
const QPoint point(qRound(logical.x() * window->devicePixelRatio()),
|
|
qRound(logical.y() * window->devicePixelRatio()));
|
|
return capture.rect().contains(point) ? capture.pixelColor(point) : QColor();
|
|
};
|
|
const auto centralPixels = [&](int page) {
|
|
const QImage capture = window->grabWindow();
|
|
const auto point = [&](QPointF pdf) {
|
|
return canvas->mapToScene(canvas->pageToCanvas(page, pdf)) * window->devicePixelRatio();
|
|
};
|
|
const QRect area = QRectF(point({80, 220}), point({480, 620})).normalized()
|
|
.toAlignedRect().intersected(capture.rect());
|
|
int dark = 0, paper = 0;
|
|
for (int y = area.top(); y <= area.bottom(); y += 2)
|
|
for (int x = area.left(); x <= area.right(); x += 2) {
|
|
const auto color = capture.pixelColor(x, y);
|
|
dark += color.lightness() < 80;
|
|
paper += color.red() > 220 && color.green() > 220 && color.blue() > 220;
|
|
}
|
|
return QPair{dark, paper};
|
|
};
|
|
|
|
// Complete both expensive pages before testing interruption: a rejected
|
|
// document or empty canvas cannot count as a successful heavy workload.
|
|
auto started = clock.elapsed(); reader.openPath(heavy);
|
|
QTRY_VERIFY_WITH_TIMEOUT(ready(), 30000);
|
|
QCOMPARE(reader.pages().size(), 2); QCOMPARE(canvas->currentPage(), 0);
|
|
canvas->setPrefetchPages(0); canvas->fitPage();
|
|
QTRY_VERIFY_WITH_TIMEOUT(canvas->viewportReady(), 30000);
|
|
const auto blue = sampleMarker(0);
|
|
QVERIFY2(blue.isValid() && blue.red() < 50 && blue.green() > 50 && blue.green() < 100 && blue.blue() > 130,
|
|
"High-resolution page did not show its original blue marker");
|
|
const auto rasterPixels = centralPixels(0);
|
|
QVERIFY2(rasterPixels.first > 1000 && rasterPixels.second > 1000,
|
|
"Decoded scan pixels and paper were not both visible in the central image area");
|
|
QVERIFY(window->grabWindow().save(output + "/raster.png"));
|
|
phases.append(QJsonObject{{"name", "complete-high-resolution-raster"}, {"elapsedMs", clock.elapsed() - started},
|
|
{"pageIndex", 0}, {"originalMarkerVisible", true},
|
|
{"darkRasterSamples", rasterPixels.first}, {"paperSamples", rasterPixels.second}});
|
|
started = clock.elapsed(); reader.execute("nav.page", {{"page", 2}});
|
|
QTRY_VERIFY_WITH_TIMEOUT(canvas->currentPage() == 1 && ready(), 30000);
|
|
const auto magenta = sampleMarker(1);
|
|
QVERIFY2(magenta.isValid() && magenta.red() > 180 && magenta.green() < 50 && magenta.blue() > 120,
|
|
"Transparent-vector page did not show its original magenta marker");
|
|
const auto vectorPixels = centralPixels(1);
|
|
QVERIFY2(vectorPixels.first > 1000 && vectorPixels.second == 0,
|
|
"Dense multiplied vector paths were absent from the central page area");
|
|
QVERIFY(window->grabWindow().save(output + "/vector.png"));
|
|
phases.append(QJsonObject{{"name", "complete-transparent-vectors"}, {"elapsedMs", clock.elapsed() - started},
|
|
{"pageIndex", 1}, {"originalMarkerVisible", true},
|
|
{"darkVectorSamples", vectorPixels.first}});
|
|
|
|
// Cancel after the real protected worker has accepted an open RPC.
|
|
reader.openPath(light); QTRY_VERIFY_WITH_TIMEOUT(ready(), 15000);
|
|
const auto retainedToken = reader.webToken();
|
|
reader.openPath(heavy); QCOMPARE(reader.state(), "Opening");
|
|
QTRY_VERIFY_WITH_TIMEOUT(sessionFor(heavy) && sessionFor(heavy)->worker
|
|
&& sessionFor(heavy)->worker->activeRequestCount("open") == 1, 10000);
|
|
QPointer<Session> cancelled = sessionFor(heavy);
|
|
started = clock.elapsed(); QTest::keyClick(window, Qt::Key_Escape);
|
|
QCOMPARE(reader.webToken(), retainedToken); QCOMPARE(reader.state(), "Ready");
|
|
QTRY_VERIFY_WITH_TIMEOUT(cancelled.isNull(), 5000);
|
|
QTRY_VERIFY_WITH_TIMEOUT(canvas->viewportReady(), 15000);
|
|
phases.append(QJsonObject{{"name", "escape-during-open-rpc"}, {"elapsedMs", clock.elapsed() - started},
|
|
{"activeOpenBeforeCancel", true}, {"retainedOldDocument", true}, {"oldSessionDestroyed", true}});
|
|
|
|
// Zoom invalidates old vector tiles. Replace while at least one new
|
|
// render RPC is outstanding; no fake sleep worker is substituted.
|
|
reader.openPath(heavy); QTRY_VERIFY_WITH_TIMEOUT(ready(), 30000);
|
|
reader.execute("nav.page", {{"page", 2}});
|
|
QTRY_VERIFY_WITH_TIMEOUT(canvas->currentPage() == 1 && ready(), 30000);
|
|
QPointer<Session> replaced = sessionFor(heavy); QVERIFY(replaced);
|
|
canvas->setZoom(350);
|
|
QTRY_VERIFY_WITH_TIMEOUT(reader.benchmarkSnapshot().value("activeRenders").toInt() == 1
|
|
&& !canvas->viewportReady(), 10000);
|
|
const auto outstanding = canvas->pendingRenderCount();
|
|
const auto replacedToken = replaced->token;
|
|
started = clock.elapsed(); reader.openPath(light);
|
|
QTRY_VERIFY_WITH_TIMEOUT(reader.webToken() != replacedToken && ready(), 15000);
|
|
QTRY_VERIFY_WITH_TIMEOUT(replaced.isNull(), 5000);
|
|
const auto lightToken = reader.webToken();
|
|
QTest::qWait(300); // Allow any already-delivered obsolete Qt callbacks to run.
|
|
QCOMPARE(reader.webToken(), lightToken); QCOMPARE(canvas->currentPage(), 0);
|
|
QCOMPARE(reader.title(), QFileInfo(light).fileName()); QVERIFY(ready());
|
|
QVERIFY(window->grabWindow().save(output + "/replacement.png"));
|
|
phases.append(QJsonObject{{"name", "replace-during-vector-render"}, {"elapsedMs", clock.elapsed() - started},
|
|
{"activeRenderBeforeReplace", true}, {"pendingRendersBeforeReplace", qint64(outstanding)},
|
|
{"oldSessionDestroyed", true}, {"replacementStableAfter300ms", true}});
|
|
reader.closeDocument(); QCOMPARE(reader.state(), "Empty"); QCOMPARE(canvas->cacheCostBytes(), 0);
|
|
QVERIFY2(ticks > 0 && longestGap < 1500, qPrintable(QString("GUI heartbeat gap: %1 ms").arg(longestGap)));
|
|
QCOMPARE(peakPressure, 0);
|
|
QCOMPARE(digest(heavy), beforeHeavy); QCOMPARE(digest(light), beforeLight);
|
|
success = true;
|
|
}
|
|
};
|
|
|
|
int main(int argc, char **argv) {
|
|
QTemporaryDir isolated; if (!isolated.isValid()) return 2;
|
|
qputenv("XDG_CONFIG_HOME", isolated.filePath("config").toUtf8());
|
|
qputenv("XDG_STATE_HOME", isolated.filePath("state").toUtf8());
|
|
qputenv("XDG_CACHE_HOME", isolated.filePath("cache").toUtf8());
|
|
registerDocumentScheme(); QtWebEngineQuick::initialize();
|
|
QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(false);
|
|
app.setApplicationName("DocView heavy PDF validation"); app.setOrganizationName("DocView");
|
|
qmlRegisterType<PdfCanvas>("DocView", 1, 0, "PdfCanvas");
|
|
HeavyPdfTest test; return QTest::qExec(&test, argc, argv);
|
|
}
|
|
|
|
#include "test_heavy_pdf.moc"
|