#include "app/controller.h" #include "app/pdf_canvas.h" #include "common/contracts.h" #include "common/memory_pressure.h" #include "core/state.h" #include "web/web_profile.h" #include "font_test_fixture.h" #include "interaction_measurement.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef Q_OS_LINUX #include #include #include #include #endif using namespace docview; class ScriptProbe : public QObject { Q_OBJECT Q_PROPERTY(QVariant value MEMBER value NOTIFY updated) public: QVariant value; signals: void updated(); }; #ifndef Q_MOC_RUN namespace { bool writeBytes(const QString &path, const QByteArray &bytes) { QFile file(path); return file.open(QIODevice::WriteOnly | QIODevice::Truncate) && file.write(bytes) == bytes.size(); } QByteArray fileHash(const QString &path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) return {}; QCryptographicHash hash(QCryptographicHash::Sha256); return hash.addData(&file) ? hash.result() : QByteArray{}; } QByteArray pagedPdf(int pageCount, int outlineCount = 0, int outlinePageStride = 0, int externalOutlines = 0) { QList objects; objects << "<< /Type /Catalog /Pages 2 0 R >>" << QByteArray{}; QByteArray kids; for (int page = 0; page < pageCount; ++page) { const auto pageObject = QByteArray::number(3 + page * 2); const auto streamObject = QByteArray::number(4 + page * 2); kids += pageObject + " 0 R "; objects << "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents " + streamObject + " 0 R /Resources << >> >>"; const QByteArray color = pageCount >= 5000 && page == pageCount - 1 ? "0.1 0.7 0.3" : "0.1 0.3 0.7"; const QByteArray stream = color + " rg 50 50 495 742 re f\n1 1 1 rg 80 100 435 " + QByteArray::number(20 + (page % 50) * 8) + " re f\n"; objects << "<< /Length " + QByteArray::number(stream.size()) + " >>\nstream\n" + stream + "endstream"; } objects[1] = "<< /Type /Pages /Count " + QByteArray::number(pageCount) + " /Kids [" + kids + "] >>"; if (outlineCount) { const int root = objects.size() + 1; objects[0] = "<< /Type /Catalog /Pages 2 0 R /Outlines " + QByteArray::number(root) + " 0 R >>"; objects << "<< /Type /Outlines /First " + QByteArray::number(root + 1) + " 0 R /Last " + QByteArray::number(root + outlineCount) + " 0 R /Count " + QByteArray::number(outlineCount) + " >>"; for (int index = 0; index < outlineCount; ++index) { const int id = objects.size() + 1; QByteArray item = "<< /Title (Chapter " + QByteArray::number(index) + ") /Parent " + QByteArray::number(root) + " 0 R"; if (index < externalOutlines) item += index % 2 ? " /A << /S /Launch /F (blocked-program) >>" : " /A << /S /URI /URI (https://example.invalid/outline) >>"; else item += " /Dest [" + QByteArray::number(3 + 2 * qMin(pageCount - 1, index * outlinePageStride)) + " 0 R /Fit]"; if (index) item += " /Prev " + QByteArray::number(id - 1) + " 0 R"; if (index + 1 < outlineCount) item += " /Next " + QByteArray::number(id + 1) + " 0 R"; objects << item + " >>"; } } QByteArray document = "%PDF-1.7\n"; QList offsets{0}; for (qsizetype i = 0; i < objects.size(); ++i) { offsets << document.size(); document += QByteArray::number(i + 1) + " 0 obj\n" + objects[i] + "\nendobj\n"; } const auto xref = document.size(); document += "xref\n0 " + QByteArray::number(offsets.size()) + "\n0000000000 65535 f \n"; for (qsizetype i = 1; i < offsets.size(); ++i) document += QByteArray::number(offsets[i]).rightJustified(10, '0') + " 00000 n \n"; document += "trailer\n<< /Size " + QByteArray::number(offsets.size()) + " /Root 1 0 R >>\nstartxref\n" + QByteArray::number(xref) + "\n%%EOF\n"; return document; } bool makeZip(const QString &path, const QList> &files) { int error = 0; zip_t *archive = zip_open(QFile::encodeName(path).constData(), ZIP_CREATE | ZIP_TRUNCATE, &error); if (!archive) return false; for (const auto &[name, bytes] : files) { auto *source = zip_source_buffer(archive, bytes.constData(), static_cast(bytes.size()), 0); if (!source) { zip_discard(archive); return false; } const auto index = zip_file_add(archive, name.constData(), source, ZIP_FL_ENC_UTF_8); if (index < 0) { zip_source_free(source); zip_discard(archive); return false; } if (zip_set_file_compression(archive, static_cast(index), ZIP_CM_STORE, 0) < 0 || zip_file_set_external_attributes(archive, static_cast(index), 0, ZIP_OPSYS_UNIX, 0100600u << 16) < 0) { zip_discard(archive); return false; } } if (zip_close(archive) < 0) { zip_discard(archive); return false; } return true; } int greenPagePixels(const QImage &image) { int count = 0; for (int y = 0; y < image.height(); y += 2) { for (int x = 0; x < image.width(); x += 2) { const auto color = image.pixelColor(x, y); if (color.red() < 45 && color.green() >= 160 && color.green() <= 200 && color.blue() >= 60 && color.blue() <= 95) ++count; } } return count; } QByteArray htmlFixture() { return R"(Fixture

First heading

Readable document

Second heading

Local target
)"; } QList> epubFixture() { return { {"mimetype", "application/epub+zip"}, {"META-INF/container.xml", R"()"}, {"OPS/book.opf", R"(urn:docview:integrationIntegration booken)"}, {"OPS/nav.xhtml", R"(Contents)"}, {"OPS/one.xhtml", R"(One

Chapter One

First spine chapter.

)"}, {"OPS/aside.xhtml", R"(Aside

Nonlinear aside

)"}, {"OPS/two.xhtml", R"(Two

Chapter Two

Second linear chapter.

)"} }; } } // namespace #endif class AppTest : public QObject { Q_OBJECT QTemporaryDir documents_; std::unique_ptr reader_; std::unique_ptr engine_; QQuickWindow *window_ = nullptr; PdfCanvas *canvas_ = nullptr; ScriptProbe probe_; QString manyPages_, stressPages_, html_, zip_, epub_, configuration_; StoragePaths testStorage() const { // XDG variables do not redirect Windows Known Folders. Inject all // writable locations so history-clear tests never touch real state. return {configuration_, documents_.filePath("state/state.json"), documents_.filePath("cache"), documents_.filePath("logs")}; } Session *activeSession() const { for (auto *object : reader_->children()) { if (auto *session = dynamic_cast(object); session && session->token == reader_->webToken()) return session; } return nullptr; } QObject *activeWeb() const { if (!window_) return nullptr; for (auto *object : window_->findChildren()) { if (object->property("documentToken").toString() == reader_->webToken() && object->property("isCurrent").toBool()) return object; } return nullptr; } bool evaluateWeb(const QString &javascript, QVariant *result) { auto *view = activeWeb(); if (!view) return false; const auto json = QJsonDocument(QJsonArray{javascript}).toJson(QJsonDocument::Compact); const auto encoded = QString::fromUtf8(json.mid(1, json.size() - 2)); QSignalSpy completed(&probe_, &ScriptProbe::updated); probe_.value = {}; QQmlExpression expression(engine_->rootContext(), view, "runJavaScript(" + encoded + ", 1, function(value) { testProbe.value = value; })"); expression.evaluate(); if (expression.hasError()) { qWarning() << expression.error(); return false; } QElapsedTimer timer; timer.start(); while (completed.isEmpty() && timer.elapsed() < 10000) QTest::qWait(5); if (completed.isEmpty()) return false; *result = probe_.value; return true; } void key(Qt::Key key, Qt::KeyboardModifiers modifiers = {}) { // QtTest's Qt::Key overload leaves the event text lowercase even with // Shift; the character overload models the layout-produced text. if (key >= Qt::Key_A && key <= Qt::Key_Z && modifiers.testFlag(Qt::ShiftModifier)) QTest::keyClick(window_, static_cast(key), modifiers); else QTest::keyClick(window_, key, modifiers); QCoreApplication::processEvents(); } void startUi(bool readOnly = false) { reader_ = std::make_unique(); reader_->initialize(configuration_, readOnly, testStorage()); engine_ = std::make_unique(); engine_->rootContext()->setContextProperty("reader", reader_.get()); engine_->rootContext()->setContextProperty("testProbe", &probe_); engine_->load(QUrl("qrc:/qml/Main.qml")); QVERIFY2(!engine_->rootObjects().isEmpty(), "Main.qml failed to instantiate"); window_ = qobject_cast(engine_->rootObjects().first()); QVERIFY(window_); reader_->attachWindow(window_); canvas_ = window_->findChild(); QVERIFY(canvas_); QVERIFY(QTest::qWaitForWindowExposed(window_, 10000)); window_->requestActivate(); QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window_, 5000); } bool resizeTestWindow(const QSize &size) { if (QGuiApplication::platformName() != "wayland" || qEnvironmentVariable("DOCVIEW_PRIVATE_WAYLAND_TEST") != "1") { window_->resize(size); return true; } // This opt-in belongs exclusively to run_wayland_validation.py's // private compositor. Select its socket and only this process's window. const auto socket = qEnvironmentVariable("SWAYSOCK"); const auto runtime = qEnvironmentVariable("XDG_RUNTIME_DIR"); if (socket.isEmpty() || !QDir::isAbsolutePath(socket) || !QFileInfo::exists(socket) || QFileInfo(socket).absolutePath() != runtime || !QFileInfo(runtime).fileName().startsWith("docview-wayland-")) return false; QProcess command; const auto resize = QString("[pid=%1] resize set width %2 px height %3 px") .arg(QCoreApplication::applicationPid()).arg(size.width()).arg(size.height()); command.start("swaymsg", {"-s", socket, "-r", "-t", "command", resize}); if (!command.waitForStarted(2000) || !command.waitForFinished(3000)) { command.kill(); command.waitForFinished(1000); return false; } const auto bytes = command.readAllStandardOutput(); if (command.exitStatus() != QProcess::NormalExit || command.exitCode() != 0 || bytes.size() > 65536) return false; QJsonParseError error; const auto response = QJsonDocument::fromJson(bytes, &error); if (error.error != QJsonParseError::NoError || !response.isArray() || response.array().isEmpty()) return false; for (const auto &value : response.array()) if (!value.isObject() || !value.toObject().value("success").isBool() || !value.toObject().value("success").toBool()) return false; // The client configure alone can precede Sway's transaction commit. // Wait for both client and compositor geometry before another resize. QElapsedTimer deadline; deadline.start(); while (deadline.elapsed() < 5000) { QTest::qWait(10); QProcess treeCommand; treeCommand.start("swaymsg", {"-s", socket, "-r", "-t", "get_tree"}); if (!treeCommand.waitForStarted(1000) || !treeCommand.waitForFinished(1000)) { treeCommand.kill(); treeCommand.waitForFinished(1000); return false; } const auto treeBytes = treeCommand.readAllStandardOutput(); if (treeCommand.exitCode() != 0 || treeBytes.size() > 1024 * 1024) return false; const auto tree = QJsonDocument::fromJson(treeBytes, &error); if (error.error != QJsonParseError::NoError || !tree.isObject()) return false; int visited = 0; std::function matches = [&](const QJsonObject &node, int depth) { if (++visited > 4096 || depth > 32) return false; if (node.value("pid").toDouble() == double(QCoreApplication::applicationPid())) { const auto rect = node.value("rect").toObject(); return rect.value("width").toInt() == size.width() && rect.value("height").toInt() == size.height(); } for (const auto *key : {"nodes", "floating_nodes"}) for (const auto &child : node.value(key).toArray()) if (matches(child.toObject(), depth + 1)) return true; return false; }; if (window_->size() == size && matches(tree.object(), 0)) return true; } return false; } bool captureRecentScreen(const QString &name) { const auto directory = qEnvironmentVariable("DOCVIEW_RECENT_SCREENSHOTS"); if (directory.isEmpty()) return true; if (!QDir().mkpath(directory)) return false; window_->update(); QTest::qWait(100); const auto image = window_->grabWindow(); return !image.isNull() && image.save(QDir(directory).filePath(name + ".png")); } private slots: void initTestCase() { QVERIFY(documents_.isValid()); manyPages_ = documents_.filePath("50 pages 日本語.pdf"); stressPages_ = documents_.filePath("5000 pages.pdf"); html_ = documents_.filePath("index.html"); zip_ = documents_.filePath("book.zip"); epub_ = documents_.filePath("book.epub"); configuration_ = documents_.filePath("config.toml"); QVERIFY(writeBytes(manyPages_, pagedPdf(50))); QVERIFY(writeBytes(stressPages_, pagedPdf(5000))); QVERIFY(writeBytes(html_, htmlFixture())); QVERIFY(writeBytes(documents_.filePath("style.css"), "body{background:rgb(240,245,250)}h1{color:rgb(10,20,30)}")); QVERIFY(writeBytes(documents_.filePath("other.html"), "

Linked destination

")); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=0\n")); QVERIFY(makeZip(zip_, {{"index.html", htmlFixture()}, {"style.css", "h1{color:rgb(40,50,60)}"}, {"other.html", "

Archive linked destination

"}})); QVERIFY(makeZip(epub_, epubFixture())); #ifdef Q_OS_WIN const QString suffix = ".exe"; #else const QString suffix; #endif QVERIFY(QFileInfo::exists(QCoreApplication::applicationDirPath() + "/docview-pdf-worker" + suffix)); QVERIFY(QFileInfo::exists(QCoreApplication::applicationDirPath() + "/docview-archive-worker" + suffix)); } void init() { QByteArray settings = "[privacy]\nremember_position=false\nrecent_documents=0\n"; if (QByteArray(QTest::currentTestFunction()) == "visibleSidebarKeepsDocumentUsable") settings += "[ui]\npages_visible=true\n"; QVERIFY(writeBytes(configuration_, settings)); startUi(); } void cleanup() { if (reader_) reader_->cancel(); engine_.reset(); window_ = nullptr; canvas_ = nullptr; reader_.reset(); QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); QCoreApplication::processEvents(); } void recentDocumentsRespectPrivacyCapAndClear() { reader_->clearHistory(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=20\n")); QVERIFY(reader_->command("reload")); auto *store = reader_->findChild(); QVERIFY(store); QList> originals; for (int i = 0; i < 8; ++i) { const auto path = documents_.filePath(QString("recent %1 日本語.pdf").arg(i)); QVERIFY(writeBytes(path, pagedPdf(1))); originals.append({path, fileHash(path)}); QVERIFY(store->remember(path, {})); } QVERIFY(store->flush()); cleanup(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=3\n")); startUi(); auto *list = window_->findChild("recentDocumentsList"); auto *clear = window_->findChild("clearRecentDocuments"); QVERIFY(list && clear); QCOMPARE(reader_->historyCount(), 8); QCOMPARE(reader_->recentDocuments().size(), 3); QCOMPARE(reader_->recentDocuments().first().toMap().value("title").toString(), QStringLiteral("recent 7 日本語.pdf")); QCOMPARE(list->property("count").toInt(), 3); QVERIFY(list->isVisible()); QTRY_VERIFY(list->hasActiveFocus()); window_->resize(1100, 760); QVERIFY(captureRecentScreen("recent-dark-1100x760")); const auto oldId = reader_->recentDocuments().first().toMap().value("documentId").toString(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=0\n")); QVERIFY(reader_->command("reload")); QVERIFY(reader_->recentDocuments().isEmpty()); QVERIFY(!list->isVisible()); QVERIFY(!clear->isVisible()); reader_->openRecent(oldId); QCOMPARE(reader_->state(), "Empty"); QVERIFY(reader_->notice().startsWith("E_OPEN_FAILED")); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=100\n")); QVERIFY(reader_->command("reload")); QCOMPARE(list->property("count").toInt(), 8); window_->resize(480, 360); QTRY_VERIFY(list->height() > 0 && list->height() <= 150); auto *screen = window_->findChild("initialScreen"); QVERIFY(screen && screen->height() < window_->height()); auto *notice = window_->findChild("noticeBanner"); QVERIFY(notice && notice->isVisible()); QTRY_VERIFY(clear->mapToScene(QPointF(0, clear->height())).y() <= notice->mapToScene(QPointF()).y()); QVERIFY(captureRecentScreen("recent-dark-480x360")); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=100\n[ui]\ntheme=\"light\"\n")); QVERIFY(reader_->command("reload")); window_->resize(1100, 760); QVERIFY(captureRecentScreen("recent-light-1100x760")); QTRY_VERIFY(list->hasActiveFocus()); key(Qt::Key_G, Qt::ShiftModifier); QCOMPARE(list->property("currentIndex").toInt(), 7); key(Qt::Key_Tab); QVERIFY(clear->hasActiveFocus()); key(Qt::Key_Return); QCOMPARE(reader_->mode(), "dialog"); auto *dialog = window_->findChild("historyDialog"); auto *confirmation = window_->findChild("historyConfirmation"); QVERIFY(dialog && confirmation); QVERIFY(confirmation->property("text").toString().startsWith("8 ")); key(Qt::Key_Escape); QTRY_COMPARE(reader_->mode(), "normal"); QCOMPARE(reader_->historyCount(), 8); key(Qt::Key_Tab); key(Qt::Key_Return); QCOMPARE(reader_->mode(), "dialog"); QVERIFY(QMetaObject::invokeMethod(dialog, "accept")); QTRY_COMPARE(reader_->historyCount(), 0); QCOMPARE(list->property("count").toInt(), 0); QVERIFY(!list->isVisible()); for (const auto &[path, hash] : originals) QCOMPARE(fileHash(path), hash); cleanup(); startUi(); QVERIFY(reader_->recentDocuments().isEmpty()); QCOMPARE(reader_->historyCount(), 0); } void recentDocumentKeyboardReopenRestoresMatchingOriginal() { reader_->clearHistory(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n")); QVERIFY(reader_->command("reload")); auto *store = reader_->findChild(); QVERIFY(store); QVERIFY(store->remember(manyPages_, {{"pageIndex", 41}, {"zoom", 100.0}})); QVERIFY(store->remember(html_, {})); QVERIFY(store->flush()); const auto pdfHash = fileHash(manyPages_), htmlHash = fileHash(html_); cleanup(); startUi(); auto *list = window_->findChild("recentDocumentsList"); QVERIFY(list); QCOMPARE(list->property("count").toInt(), 2); QTRY_VERIFY(list->hasActiveFocus()); key(Qt::Key_J); QCOMPARE(list->property("currentIndex").toInt(), 1); key(Qt::Key_Up); QCOMPARE(list->property("currentIndex").toInt(), 0); key(Qt::Key_Down); QCOMPARE(list->property("currentIndex").toInt(), 1); key(Qt::Key_Return); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QCOMPARE(reader_->format(), "pdf"); QCOMPARE(reader_->title(), QFileInfo(manyPages_).fileName()); QCOMPARE(canvas_->currentPage(), 41); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QVERIFY(!list->isVisible()); QCOMPARE(fileHash(manyPages_), pdfHash); QCOMPARE(fileHash(html_), htmlHash); } void recentDocumentMissingOrReplaced_data() { QTest::addColumn("replaced"); QTest::newRow("missing") << false; QTest::newRow("replaced-with-identical-bytes-and-mtime") << true; } void recentDocumentMissingOrReplaced() { QFETCH(bool, replaced); reader_->clearHistory(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n")); QVERIFY(reader_->command("reload")); const auto path = documents_.filePath(replaced ? "replaced recent.pdf" : "missing recent.pdf"); const auto bytes = pagedPdf(50); QVERIFY(writeBytes(path, bytes)); const auto identity = StateStore::fileIdentity(path); auto *store = reader_->findChild(); QVERIFY(store); QVERIFY(store->remember(path, {{"pageIndex", 41}, {"zoom", 100.0}})); const auto hash = fileHash(path); QVERIFY(QFile::rename(path, path + ".original")); if (replaced) { QVERIFY(writeBytes(path, bytes)); QFile newFile(path); QVERIFY(newFile.open(QIODevice::ReadWrite)); QVERIFY(newFile.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime)); newFile.close(); const auto current = StateStore::fileIdentity(path); QCOMPARE(current.value("size"), identity.value("size")); QCOMPARE(current.value("mtime"), identity.value("mtime")); QVERIFY(current.value("fileIdentity") != identity.value("fileIdentity")); } QTRY_COMPARE(reader_->recentDocuments().size(), 1); key(Qt::Key_Return); QCOMPARE(reader_->state(), "Empty"); QVERIFY(reader_->webToken().isEmpty()); QVERIFY(reader_->notice().startsWith("E_OPEN_FAILED")); QVERIFY(reader_->notice().contains("Ctrl+O")); QVERIFY(reader_->notice().contains(replaced ? QStringLiteral("変更または置換") : QStringLiteral("見つからない"))); QCOMPARE(fileHash(path + ".original"), hash); if (replaced) { QCOMPARE(fileHash(path), hash); reader_->openPath(path); // Explicit file selection may open the new original, without the old position. QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QCOMPARE(canvas_->currentPage(), 0); QCOMPARE(fileHash(path), hash); } } void initialUiAndCommandEntry() { QCOMPARE(reader_->state(), "Empty"); QCOMPARE(reader_->title(), "DocView"); QCOMPARE(window_->property("tocVisible").toBool(), false); QCOMPARE(window_->property("pagesVisible").toBool(), false); QCOMPARE(window_->property("statusVisible").toBool(), true); key(Qt::Key_Colon); QCOMPARE(reader_->mode(), "command"); key(Qt::Key_J); key(Qt::Key_4); QCOMPARE(reader_->mode(), "command"); QVERIFY(reader_->pending().isEmpty()); key(Qt::Key_Escape); QCOMPARE(reader_->mode(), "normal"); key(Qt::Key_Colon); QVERIFY(QGuiApplication::focusObject()); QVERIFY(QGuiApplication::focusObject()->setProperty("text", "help")); key(Qt::Key_Return); QCOMPARE(reader_->mode(), "dialog"); key(Qt::Key_Escape); QTRY_COMPARE(reader_->mode(), "normal"); key(Qt::Key_Z); key(Qt::Key_S); QCOMPARE(window_->property("statusVisible").toBool(), false); key(Qt::Key_Z); key(Qt::Key_S); QCOMPARE(window_->property("statusVisible").toBool(), true); QVERIFY(!reader_->command("page -1")); QCOMPARE(reader_->state(), "Empty"); } void pdfNumericNavigationRenderingAndSourceIntegrity() { const auto originalHash = fileHash(manyPages_); QSignalSpy presented(reader_.get(), &Controller::framePresented); reader_->openPath(manyPages_); QCOMPARE(reader_->state(), "Opening"); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QCOMPARE(reader_->format(), "pdf"); QCOMPARE(reader_->pages().size(), 50); QTRY_VERIFY_WITH_TIMEOUT(!presented.isEmpty(), 15000); key(Qt::Key_4); key(Qt::Key_2); key(Qt::Key_G, Qt::ShiftModifier); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 41, 10000); QVERIFY(reader_->position().startsWith("42 / 50")); const auto before = canvas_->currentPage(); key(Qt::Key_9); key(Qt::Key_9); key(Qt::Key_G, Qt::ShiftModifier); QCOMPARE(canvas_->currentPage(), before); QVERIFY(reader_->notice().contains("範囲外")); key(Qt::Key_G); key(Qt::Key_G); QTRY_COMPARE(canvas_->currentPage(), 0); const auto zoom = canvas_->zoom(); reader_->execute("zoom.in"); QVERIFY(canvas_->zoom() > zoom); reader_->execute("view.rotate", {{"degrees", 90}}); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); const QImage frame = window_->grabWindow(); QVERIFY(!frame.isNull()); int bluePixels = 0; for (int y = 0; y < frame.height(); y += 2) { for (int x = 0; x < frame.width(); x += 2) { const auto color = frame.pixelColor(x, y); if (color.red() < 45 && color.green() >= 60 && color.green() <= 95 && color.blue() >= 160 && color.blue() <= 200) ++bluePixels; } } QVERIFY2(bluePixels > 500, "Rendered frame did not contain the fixture's original blue page content"); QCOMPARE(fileHash(manyPages_), originalHash); } void initialPageAppliesOnlyToRequestedDocument() { reader_->openPath(manyPages_, {}, 42); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 41, 10000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 0, 10000); const auto previousTitle = reader_->title(); reader_->openPath(QFINDTESTDATA("fixtures/pdf/corrupt.pdf"), {}, 2); QCOMPARE(reader_->state(), "Opening"); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QCOMPARE(reader_->title(), previousTitle); QCOMPARE(canvas_->currentPage(), 0); reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QCOMPARE(canvas_->currentPage(), 0); } void largePdfFarJumpsRemainResponsiveAndCancel() { const auto originalHash = fileHash(stressPages_); QElapsedTimer elapsed; elapsed.start(); qint64 previousTick = 0, longestGap = 0; int ticks = 0; QTimer heartbeat; connect(&heartbeat, &QTimer::timeout, this, [&] { const auto now = elapsed.elapsed(); longestGap = qMax(longestGap, now - previousTick); previousTick = now; ++ticks; }); heartbeat.start(10); QSignalSpy requested(canvas_, &PdfCanvas::tileRequested); QSignalSpy metadata(canvas_, &PdfCanvas::pageNeeded); reader_->openPath(stressPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QCOMPARE(reader_->pages().size(), 5000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000); QVERIFY(ticks > 0); const int firstPageTicks = ticks; requested.clear(); reader_->execute("nav.page", {{"page", 5000}}); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 4999, 10000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000); QVERIFY(reader_->position().startsWith("5000 / 5000")); QVERIFY(ticks > firstPageTicks); QVERIFY(!metadata.isEmpty()); QVERIFY(!requested.isEmpty()); // A far jump must request the destination rather than render all // intervening pages. Both the visible page and nearby prefetch count. QSet renderedPages; for (const auto &request : requested) renderedPages.insert(request[0].toInt()); QVERIFY(renderedPages.contains(4999)); QVERIFY2(renderedPages.size() < 10, "Far jump rendered intervening PDF pages"); requested.clear(); // Let each jump dispatch before changing direction, so the test // exercises worker replies from obsolete render revisions. for (const int page : {2500, 42, 4999, 1250}) { const auto previousRequests = requested.size(); reader_->execute("nav.page", {{"page", page}}); QTRY_VERIFY_WITH_TIMEOUT(requested.size() > previousRequests, 10000); } reader_->execute("zoom.in"); reader_->execute("zoom.out"); reader_->execute("nav.page", {{"page", 5000}}); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 4999, 10000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000); QSet revisions; for (const auto &request : requested) revisions.insert(request[2].toLongLong()); QVERIFY2(revisions.size() >= 2, "Rapid jumps did not dispatch different render revisions"); QTest::qWait(100); QCOMPARE(canvas_->currentPage(), 4999); QVERIFY2(greenPagePixels(window_->grabWindow()) > 500, "The final viewport did not show the distinct green last page"); const auto stressTitle = reader_->title(); reader_->openPath(epub_); QCOMPARE(reader_->state(), "Opening"); reader_->cancel(); QCOMPARE(reader_->state(), "Ready"); QCOMPARE(reader_->title(), stressTitle); QCOMPARE(canvas_->currentPage(), 4999); // Replace while far-away work is queued and ensure no old frame or // callback can reinstate the large document after the commit. reader_->execute("nav.page", {{"page", 1234}}); QTest::qWait(22); reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000); QCOMPARE(reader_->pages().size(), 50); QCOMPARE(canvas_->currentPage(), 0); QTest::qWait(200); QCOMPARE(reader_->pages().size(), 50); QCOMPARE(canvas_->currentPage(), 0); QCOMPARE(greenPagePixels(window_->grabWindow()), 0); heartbeat.stop(); // Detect event-loop starvation with a generous CI bound. The // separate performance harness measures the specified p95 targets. QVERIFY2(longestGap < 1500, qPrintable(QString("GUI stalled for %1 ms").arg(longestGap))); qInfo().nospace() << "T03 synthetic 5000 pages: " << elapsed.elapsed() << " ms, " << ticks << " event-loop ticks, longest gap " << longestGap << " ms"; QCOMPARE(fileHash(stressPages_), originalHash); } void configuredNontextLogicalKeys() { reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QVERIFY(writeBytes(configuration_, R"([privacy] remember_position=false recent_documents=0 [[keybindings]] mode='normal' context='global' keys=['F2'] command='panel.status.toggle' [[keybindings]] mode='normal' context='content' keys=['Home'] command='nav.document_start' [[keybindings]] mode='normal' context='content' keys=['Ctrl+Enter'] command='nav.page' args={page=42} [[keybindings]] mode='normal' context='content' keys=['Shift+Tab'] command='nav.page' args={page=2} )")); QVERIFY(reader_->command("reload")); QVERIFY2(!reader_->notice().startsWith("E_CONFIG"), qPrintable(reader_->notice())); key(Qt::Key_F2); QCOMPARE(window_->property("statusVisible").toBool(), false); key(Qt::Key_Return, Qt::ControlModifier); QTRY_COMPARE(canvas_->currentPage(), 41); key(Qt::Key_Home); QTRY_COMPARE(canvas_->currentPage(), 0); key(Qt::Key_Tab, Qt::ShiftModifier); QTRY_COMPARE(canvas_->currentPage(), 1); key(Qt::Key_Home); key(Qt::Key_Backtab); QTRY_COMPARE(canvas_->currentPage(), 1); key(Qt::Key_Colon); QCOMPARE(reader_->mode(), "command"); key(Qt::Key_F2); key(Qt::Key_Home); QCOMPARE(window_->property("statusVisible").toBool(), false); QCOMPARE(canvas_->currentPage(), 1); key(Qt::Key_Escape); key(Qt::Key_F2); QCOMPARE(window_->property("statusVisible").toBool(), true); } void reloadingHiddenFocusedPanelsReturnsToContent_data() { QTest::addColumn("panel"); QTest::newRow("toc") << QString("toc"); QTest::newRow("pages") << QString("pages"); } void visibleSidebarKeepsDocumentUsable() { QVERIFY(window_->property("pagesVisible").toBool()); reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QVERIFY(!reader_->outline().isEmpty()); auto *sidebar = window_->findChild("navigationSidebar"); auto *content = window_->findChild("documentContent"); QVERIFY(sidebar && content); for (const int width : {1100, 480, 1100}) { window_->resize(width, 800); QTRY_COMPARE(window_->width(), width); const QStringList transitions{{}, "panel.pages.toggle", "panel.toc.toggle", "panel.pages.toggle", "panel.toc.toggle", "panel.pages.toggle", "panel.pages.toggle"}; for (const auto &command : transitions) { if (!command.isEmpty()) reader_->execute(command); QTRY_VERIFY_WITH_TIMEOUT(content->width() >= window_->width() * .6 - 2, 5000); if (sidebar->isVisible()) { QTRY_VERIFY_WITH_TIMEOUT(sidebar->width() > 0, 5000); QTRY_VERIFY_WITH_TIMEOUT(sidebar->width() <= window_->width() * .4 + 1, 5000); } else { QTRY_VERIFY_WITH_TIMEOUT(content->width() >= window_->width() - 2, 5000); } QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QVERIFY(canvas_->width() >= window_->width() * .6 - 2); } } } void invalidCapabilitiesCannotDispatchCommands_data() { QTest::addColumn("fault"); for (const auto *fault : {"missing", "unknown-state", "state-type", "unknown-name", "missing-reason"}) QTest::newRow(fault) << QString::fromLatin1(fault); } void invalidCapabilitiesCannotDispatchCommands() { QFETCH(QString, fault); reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); auto *session = activeSession(); QVERIFY(session); const auto original = session->meta.value("capabilities").toMap(); const auto originalReasons = session->meta.value("capabilityReasons").toMap(); QVERIFY(validateCapabilities(original, originalReasons)); auto altered = original; if (fault == "missing") altered.remove("pageNavigation"); else if (fault == "unknown-state") altered["pageNavigation"] = "enabled"; else if (fault == "state-type") altered["pageNavigation"] = true; else if (fault == "unknown-name") altered["futureCapability"] = "supported"; else if (fault == "missing-reason") session->meta["capabilityReasons"] = QVariantMap{}; session->meta["capabilities"] = altered; QSignalSpy ui(reader_.get(), &Controller::uiCommand); reader_->execute("nav.page", {{"page", 2}}); reader_->execute("search.open"); reader_->execute("panel.toc.toggle"); QCOMPARE(canvas_->currentPage(), 0); QCOMPARE(ui.size(), 0); QCOMPARE(reader_->mode(), "normal"); QVERIFY(reader_->notice().startsWith("E_WORKER_CRASH")); session->meta["capabilities"] = original; session->meta["capabilityReasons"] = originalReasons; reader_->execute("nav.chapter_next"); QVERIFY(reader_->notice().startsWith("E_CHAPTER_NAVIGATION_UNSUPPORTED")); reader_->execute("nav.page", {{"page", 2}}); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 1, 10000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); } void reloadingHiddenFocusedPanelsReturnsToContent() { QFETCH(QString, panel); const QByteArray binding = "\n[[keybindings]]\nmode='normal'\ncontext='global'\nkeys=['F5']\ncommand='config.reload'\n"; const QByteArray privacy = "[privacy]\nremember_position=false\nrecent_documents=0\n"; QVERIFY(writeBytes(configuration_, privacy + "[ui]\n" + panel.toUtf8() + "_visible=true\n" + binding)); reader_->execute("config.reload"); reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); key(Qt::Key_W, Qt::ControlModifier); key(Qt::Key_W); QCOMPARE(reader_->context(), panel); QVERIFY(window_->property(qPrintable(panel + "Visible")).toBool()); QVERIFY(writeBytes(configuration_, privacy + "[ui]\n" + panel.toUtf8() + "_visible=false\n" + binding)); // Invoke the configured normal-mode binding; command entry's focus // restoration would hide this regression. key(Qt::Key_F5); QVERIFY(!window_->property(qPrintable(panel + "Visible")).toBool()); QCOMPARE(reader_->context(), "content"); QVERIFY(canvas_->hasActiveFocus()); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); reader_->execute("nav.document_start"); const auto before = canvas_->offset(); key(Qt::Key_J); QVERIFY(canvas_->offset() > before); } void explicitCloseClearsResourcesAndRejectsPendingOpen() { const auto path = QFINDTESTDATA("fixtures/pdf/navigation.pdf"); const auto original = fileHash(path); reader_->openPath(path); QTRY_VERIFY_WITH_TIMEOUT(reader_->state() == "Ready" && canvas_->viewportReady(), 15000); QPointer previous = activeSession(); QVERIFY(previous); const auto token = previous->token; reader_->openPath(html_); // Closing also cancels a staged replacement. reader_->closeDocument(); QCOMPARE(reader_->state(), "Empty"); QCOMPARE(reader_->format(), QString{}); QVERIFY(reader_->outline().isEmpty()); QVERIFY(reader_->pages().isEmpty()); QVERIFY(reader_->benchmarkSnapshot().isEmpty()); QCOMPARE(canvas_->cacheCostBytes(), qint64(0)); QCOMPARE(canvas_->pendingRenderCount(), qsizetype(0)); reader_->webLoaded(token, true); reader_->webIndex(token, {{"headings", QVariantList{}}}); QTest::qWait(200); QCOMPARE(reader_->state(), "Empty"); QTRY_VERIFY(previous.isNull()); reader_->closeDocument(); // Idempotent while already empty. reader_->openPath(path); QTRY_VERIFY_WITH_TIMEOUT(reader_->state() == "Ready" && canvas_->viewportReady(), 15000); QVERIFY(activeSession()->token != token); QCOMPARE(fileHash(path), original); reader_->openPath(html_); QTRY_VERIFY_WITH_TIMEOUT(reader_->format() == "html" && reader_->state() == "Ready", 15000); QPointer web = activeSession(); reader_->closeDocument(); QTRY_VERIFY(web.isNull()); QCOMPARE(reader_->format(), QString{}); QCOMPARE(reader_->state(), "Empty"); } void formOnlyStructureProvidesExactHeadingNavigation() { const auto path = QFINDTESTDATA("fixtures/pdf/headings/form-only-structure.pdf"); const auto original = fileHash(path); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); auto *session = activeSession(); QVERIFY(session); QTRY_COMPARE_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString(), "supported", 10000); QVERIFY(!session->meta.value("capabilityReasons").toMap().contains("headings")); QVERIFY(!session->meta.value("warnings").toStringList().join('\n').contains("E_PDF_STRUCTURE_LIMITED")); canvas_->setZoom(800); canvas_->goTo(0); reader_->execute("nav.heading_next"); const auto location = canvas_->location(); QCOMPARE(location.value("pageIndex").toInt(), 0); // Authored baseline is 130 pt, with a 16 pt glyph height after the Form translation. QVERIFY(location.value("yPt").toDouble() > 130.0 && location.value("yPt").toDouble() < 147.0); QVERIFY(!reader_->notice().contains("E_PDF_STRUCTURE_LIMITED")); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QCOMPARE(reader_->state(), "Ready"); QCOMPARE(fileHash(path), original); } void pagePrecisionHeadingDoesNotReselectItsOwnStart() { const auto path = QFINDTESTDATA("fixtures/pdf/headings/vector-heading.pdf"); const auto original = fileHash(path); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); auto *session = activeSession(); QVERIFY(session); QTRY_COMPARE_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString(), "supported", 10000); canvas_->setZoom(800); canvas_->goTo(0); canvas_->scroll(0, 300); QVERIFY(canvas_->location().value("progression").toDouble() > .01); reader_->execute("nav.heading_previous"); QCOMPARE(canvas_->currentPage(), 0); QVERIFY(canvas_->location().value("progression").toDouble() < .0001); const auto atStart = canvas_->offset(); reader_->execute("nav.heading_previous"); QCOMPARE(canvas_->offset(), atStart); QVERIFY(reader_->notice().contains("見出しの端")); reader_->execute("nav.heading_next"); QCOMPARE(canvas_->offset(), atStart); QVERIFY(reader_->notice().contains("見出しの端")); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QCOMPARE(fileHash(path), original); } void rotatedPdfHeadingsFollowDisplayedReadingPosition() { const auto path = QFINDTESTDATA("fixtures/pdf/headings/rotated-tags.pdf"); const auto original = fileHash(path); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); auto *session = activeSession(); QVERIFY(session); QTRY_COMPARE_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString(), "supported", 10000); canvas_->setZoom(800); canvas_->goTo(0); const double start = canvas_->offset(); reader_->execute("nav.heading_next"); const double first = canvas_->offset(); QVERIFY(first > start + 100); // Intrinsic Rotate 90 makes the heading's PDF x the reading-axis // coordinate. Its original y must not be compared to the top's y. auto location = canvas_->location(); QVERIFY(location.value("xPt").toDouble() >= 50 && location.value("xPt").toDouble() <= 54); reader_->execute("nav.heading_next"); const double second = canvas_->offset(); QVERIFY(second > first + 40); reader_->execute("nav.heading_previous"); QVERIFY(std::abs(canvas_->offset() - first) < 1); reader_->execute("nav.heading_previous"); QVERIFY(reader_->notice().contains("見出しの端")); QVERIFY(std::abs(canvas_->offset() - first) < 1); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QCOMPARE(fileHash(path), original); } void pdfIndexBudgetKeepsDocumentReady_data() { QTest::addColumn("outline"); QTest::newRow("outline-pagination") << true; QTest::newRow("tagged-headings") << false; } void pdfIndexBudgetKeepsDocumentReady() { QFETCH(bool, outline); const QString path = outline ? documents_.filePath("bounded-outline.pdf") : QFINDTESTDATA("fixtures/pdf/navigation.pdf"); if (outline) QVERIFY(writeBytes(path, pagedPdf(4, 160))); bool injected = false; QObject injectionLifetime; connect(reader_.get(), &Controller::documentChanged, &injectionLifetime, [&] { if (auto *session = activeSession()) { // Exercise the real next worker reply at the budget boundary // without allocating a 32 MiB synthetic index in every run. session->navigationBytes = MaxNavigationBytes - 1; injected = true; } }); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QVERIFY(injected); auto *session = activeSession(); QVERIFY(session && session->worker); QSignalSpy failures(session->worker, &WorkerProcess::failed); QTRY_VERIFY_WITH_TIMEOUT(session->meta.value("warnings").toStringList().join('\n').contains("E_PDF_INDEX_PARTIAL"), 10000); QCOMPARE(reader_->state(), "Ready"); QVERIFY(!session->resourcesStopped); if (outline) { QCOMPARE(session->meta.value("nextOutlineCursor").toInt(), -1); QCOMPARE(reader_->outline().size(), 64); } bool pong = false; session->worker->request("ping", {}, [&](const QCborMap &reply) { pong = reply.value("result").toMap().value("ready").toBool(); }); QTRY_VERIFY_WITH_TIMEOUT(pong, 5000); reader_->execute("nav.page", {{"page", reader_->pages().size()}}); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), reader_->pages().size() - 1, 10000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QCOMPARE(reader_->state(), "Ready"); QCOMPARE(failures.size(), 0); } void archiveFailureCompletesPendingResourceRequests() { reader_->openPath(zip_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); auto *session = activeSession(); QVERIFY(session && session->worker && session->handler); QList completions; session->extractWaiters["pending-a"] << [&](ResourceFailure failure) { completions << failure; }; session->extractWaiters["pending-a"] << [&](ResourceFailure failure) { completions << failure; }; session->extractWaiters["pending-b"] << [&](ResourceFailure failure) { completions << failure; }; QVERIFY(QMetaObject::invokeMethod(session->worker, "failed", Qt::DirectConnection, Q_ARG(QString, "E_WORKER_CRASH"), Q_ARG(QString, "Injected active-worker failure"))); QCOMPARE(reader_->state(), "Recovering"); QCOMPARE(completions, QList({ResourceFailure::WorkerFailed, ResourceFailure::WorkerFailed, ResourceFailure::WorkerFailed})); QVERIFY(session->extractWaiters.isEmpty()); QVERIFY(session->resourcesStopped); QSignalSpy blocked(session->handler, &ResourceHandler::blocked); QVariant result; QVERIFY(evaluateWeb("(()=>{const image=document.createElement('img');image.id='after-revoke';image.src='other.html';document.body.append(image);return true;})()", &result)); QTRY_VERIFY_WITH_TIMEOUT(evaluateWeb("document.getElementById('after-revoke').complete && document.getElementById('after-revoke').naturalWidth===0", &result) && result.toBool(), 5000); QCOMPARE(blocked.size(), 0); // Revoked sessions must not issue new warnings. QVERIFY(session->extractWaiters.isEmpty()); reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTest::qWait(100); QCOMPARE(completions.size(), 3); // Disposal must not complete them twice. } void latePdfFontFailureCanReopenFreshWorker_data() { QTest::addColumn("operation"); QTest::newRow("prefetch") << 0; QTest::newRow("search") << 1; QTest::newRow("cancelled-search") << 2; QTest::newRow("page-metadata") << 3; } void latePdfFontFailureCanReopenFreshWorker() { QFETCH(int, operation); const auto path = documents_.filePath("late-font-recovery.pdf"); QVERIFY(writeBytes(path, deferredFontPdf())); const auto original = fileHash(path); reader_->openPath(path); canvas_->setPrefetchPages(0); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); auto *session = activeSession(); QVERIFY(session && session->worker); const auto previousToken = session->token; const auto previousGeneration = session->generation; if (operation == 0) canvas_->updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}}); canvas_->setZoom(300); canvas_->goTo(255); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000); const auto cached = canvas_->cacheCostBytes(); QVERIFY(cached > 0); int maps = 0; session->worker->setFontRequestHandler([&](const QString &fontOperation, const QCborMap &, std::function done) { QCOMPARE(fontOperation, "font.map"); ++maps; if (operation == 2) reader_->cancel(); // Obsolete search still poisoned this worker's font cache. done({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Injected late font failure"}}}}); }); if (operation == 0) canvas_->setPrefetchPages(1); else if (operation == 3) canvas_->goTo(256); else reader_->search("Late"); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Recovering", 15000); QVERIFY(maps > 0); QVERIFY(session->resourcesStopped); if (operation == 1 || operation == 2) QCOMPARE(session->meta.value("capabilities").toMap().value("textSearch").toString(), "loading"); QVERIFY(reader_->notice().contains("E_FONT_FAILED")); QVERIFY(reader_->notice().contains(QStringLiteral("開き直"))); QVERIFY(canvas_->cacheCostBytes() >= cached); if (operation != 3) QVERIFY(canvas_->viewportReady()); reader_->openPath(path); canvas_->setPrefetchPages(0); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); session = activeSession(); QVERIFY(session); QVERIFY(session->token != previousToken); QVERIFY(session->generation > previousGeneration); canvas_->goTo(256); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QCOMPARE(reader_->state(), "Ready"); QVERIFY(!session->resourcesStopped); QCOMPARE(fileHash(path), original); } void latePdfFontWarningPersistsInDocumentInfo() { const auto path = documents_.filePath("late-font-warning.pdf"); QVERIFY(writeBytes(path, deferredFontPdf())); reader_->openPath(path); canvas_->setPrefetchPages(0); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); auto *session = activeSession(); QVERIFY(session && session->worker); canvas_->updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}}); canvas_->setZoom(300); canvas_->goTo(255); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000); session->worker->setFontRequestHandler([](const QString &, const QCborMap &, std::function done) { done({{"found", false}}); // A genuine absence permits PDFium's built-in alternative. }); canvas_->setPrefetchPages(1); QTRY_VERIFY_WITH_TIMEOUT(session->meta.value("warnings").toStringList().join('\n').contains(QStringLiteral("内蔵")), 15000); QCOMPARE(reader_->state(), "Ready"); QVERIFY(!session->resourcesStopped); QSignalSpy commands(reader_.get(), &Controller::uiCommand); reader_->execute("document.info"); QCOMPARE(commands.size(), 1); QVERIFY(commands[0][1].toMap().value("text").toString().contains(QStringLiteral("内蔵"))); const auto warnings = session->meta.value("warnings").toStringList(); QVERIFY(warnings.size() <= 32); for (const auto &warning : warnings) QVERIFY(warning.size() <= 1024); } void initialPdfFontErrorPreservesCodeAndOldDocument() { reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); const auto previous = reader_->webToken(); const auto path = QFINDTESTDATA("fixtures/pdf/navigation.pdf"); reader_->openPath(path); Session *pending = nullptr; for (auto *object : reader_->children()) if (auto *candidate = dynamic_cast(object); candidate && candidate->token != previous && !candidate->resourcesStopped) pending = candidate; QVERIFY(pending && pending->worker); pending->worker->setFontRequestHandler([](const QString &, const QCborMap &, std::function done) { done({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Injected initial font failure"}}}}); }); QTRY_VERIFY_WITH_TIMEOUT(reader_->notice().contains("E_FONT_FAILED"), 15000); QCOMPARE(reader_->webToken(), previous); QCOMPARE(reader_->state(), "Ready"); QVERIFY(canvas_->viewportReady()); QVERIFY(!reader_->notice().contains("E_PDF_CORRUPT")); } void activeFontFailureSurvivesStagingRollback_data() { QTest::addColumn("cancelStaging"); QTest::newRow("staging-fails") << false; QTest::newRow("staging-cancelled") << true; } void activeFontFailureSurvivesStagingRollback() { QFETCH(bool, cancelStaging); const auto path = documents_.filePath("active-font-rollback.pdf"); QVERIFY(writeBytes(path, deferredFontPdf())); reader_->openPath(path); canvas_->setPrefetchPages(0); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); auto *active = activeSession(); QVERIFY(active && active->worker); const auto token = active->token; canvas_->updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}}); canvas_->setZoom(300); canvas_->goTo(255); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && active->worker->idle(), 15000); std::function oldReply, newReply; active->worker->setFontRequestHandler([&](const QString &, const QCborMap &, std::function done) { oldReply = std::move(done); }); canvas_->setPrefetchPages(1); QTRY_VERIFY_WITH_TIMEOUT(bool(oldReply), 15000); reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); QCOMPARE(reader_->state(), "Opening"); Session *staging = nullptr; for (auto *object : reader_->children()) if (auto *candidate = dynamic_cast(object); candidate && candidate != active && !candidate->resourcesStopped) staging = candidate; QVERIFY(staging && staging->worker); staging->worker->setFontRequestHandler([&](const QString &, const QCborMap &, std::function done) { newReply = std::move(done); }); QTRY_VERIFY_WITH_TIMEOUT(bool(newReply), 15000); QCOMPARE(reader_->state(), "Opening"); // The old worker is still allowed to report a fatal error. oldReply({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Active font failure"}}}}); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Recovering", 15000); QVERIFY(active->resourcesStopped); QCOMPARE(reader_->webToken(), token); if (cancelStaging) reader_->cancel(); else { QPointer failedAttempt = staging; newReply({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Staging font failure"}}}}); QTRY_VERIFY_WITH_TIMEOUT(failedAttempt.isNull(), 15000); QVERIFY(reader_->notice().contains("E_FONT_FAILED")); QVERIFY(reader_->notice().contains("navigation.pdf")); QVERIFY(reader_->notice().contains(QStringLiteral("開き直してください"))); QVERIFY(!reader_->notice().contains("Staging font failure")); } QCOMPARE(reader_->state(), "Recovering"); QCOMPARE(reader_->webToken(), token); QVERIFY(active->resourcesStopped); QVERIFY(canvas_->viewportReady()); } void clearedHistorySurvivesCloseAndReopen() { reader_->clearHistory(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=20\n")); QVERIFY(reader_->command("reload")); reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QTRY_COMPARE_WITH_TIMEOUT(reader_->historyCount(), 1, 5000); auto *store = reader_->findChild(); QVERIFY(store); QVERIFY(!store->readOnly()); const auto stateFile = store->filePath(); // Exercise the actual debounce and on-disk state, not only count(). auto diskCount = [&] { QFile file(stateFile); if (!file.open(QIODevice::ReadOnly)) return -1; return int(QJsonDocument::fromJson(file.readAll()).object().value("documents").toArray().size()); }; QTRY_COMPARE_WITH_TIMEOUT(diskCount(), 1, 5000); reader_->clearHistory(); QCOMPARE(reader_->historyCount(), 0); QCOMPARE(diskCount(), 0); // A repaint at the same location must not repopulate cleared state. window_->update(); QTest::qWait(100); QCOMPARE(reader_->historyCount(), 0); cleanup(); QCOMPARE(diskCount(), 0); Controller reopened; reopened.initialize(configuration_, false, testStorage()); QCOMPARE(reopened.historyCount(), 0); } void unpresentedJumpDoesNotReplaceSavedPosition() { reader_->clearHistory(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=20\n")); QVERIFY(reader_->command("reload")); reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QTRY_COMPARE_WITH_TIMEOUT(reader_->historyCount(), 1, 5000); auto *store = reader_->findChild(); QVERIFY(store); QCOMPARE(store->restore(manyPages_).value("location").toMap().value("pageIndex").toInt(), 0); const auto stateFile = store->filePath(); reader_->execute("nav.page", {{"page", 42}}); QCOMPARE(canvas_->currentPage(), 41); QVERIFY(!canvas_->viewportReady()); // Close synchronously before the newly requested tiles can arrive. cleanup(); StateStore reopened(stateFile); const auto restored = reopened.restore(manyPages_).value("location").toMap(); QVERIFY(!restored.isEmpty()); QCOMPARE(restored.value("pageIndex").toInt(), 0); QVERIFY(reopened.clearHistory()); } void panelFocusAndInputProtection() { reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QVERIFY(!reader_->outline().isEmpty()); const auto page = canvas_->currentPage(); key(Qt::Key_T); QCOMPARE(window_->property("tocVisible").toBool(), true); QCOMPARE(reader_->context(), "content"); QCOMPARE(canvas_->currentPage(), page); key(Qt::Key_P); QCOMPARE(window_->property("pagesVisible").toBool(), true); key(Qt::Key_W, Qt::ControlModifier); key(Qt::Key_W); QCOMPARE(reader_->context(), "toc"); key(Qt::Key_J); QCOMPARE(canvas_->currentPage(), page); key(Qt::Key_W, Qt::ControlModifier); key(Qt::Key_W); QCOMPARE(reader_->context(), "pages"); key(Qt::Key_P); QCOMPARE(reader_->context(), "content"); QCOMPARE(window_->property("pagesVisible").toBool(), false); reader_->execute("nav.page", {{"page", 1}}); const auto offset = canvas_->offset(); reader_->setMode("command"); key(Qt::Key_J); key(Qt::Key_2); key(Qt::Key_G, Qt::ShiftModifier); QCOMPARE(canvas_->offset(), offset); reader_->setMode("normal"); QInputMethodEvent composition(QString::fromUtf8("変換中"), {}); QCoreApplication::sendEvent(window_, &composition); key(Qt::Key_J); key(Qt::Key_2); key(Qt::Key_G, Qt::ShiftModifier); QCOMPARE(canvas_->offset(), offset); QVERIFY(reader_->pending().isEmpty()); QInputMethodEvent finished; QCoreApplication::sendEvent(window_, &finished); } void pdfSearchCapabilityFollowsCompletedEvidence_data() { QTest::addColumn("hasText"); QTest::addColumn("query"); QTest::newRow("text-with-match") << true << QStringLiteral("Needle"); QTest::newRow("text-without-match") << true << QStringLiteral("DefinitelyAbsentSearchTerm"); QTest::newRow("no-text") << false << QStringLiteral("Needle"); } void pdfSearchCapabilityFollowsCompletedEvidence() { QFETCH(bool, hasText); QFETCH(QString, query); const auto empty = documents_.filePath("search-without-text.pdf"); QVERIFY(writeBytes(empty, pagedPdf(3))); reader_->openPath(hasText ? QFINDTESTDATA("fixtures/pdf/navigation.pdf") : empty); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); auto *session = activeSession(); QVERIFY(session && session->worker); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000); const auto capability = [&] { return session->meta.value("capabilities").toMap().value("textSearch").toString(); }; QCOMPARE(capability(), "loading"); // The request is sent, but no event loop can consume its response // before cancel changes the revision. Even an empty document stays // unclassified when that in-flight response arrives. reader_->search(query); QVERIFY(!session->worker->idle()); reader_->cancel(); QTRY_VERIFY_WITH_TIMEOUT(session->worker->idle(), 15000); QCOMPARE(capability(), "loading"); reader_->search(query); QTRY_COMPARE_WITH_TIMEOUT(capability(), hasText ? "supported" : "unavailable", 15000); QTRY_VERIFY_WITH_TIMEOUT(session->worker->idle(), 15000); if (!hasText) QVERIFY(reader_->notice().contains(QStringLiteral("検索できる文字情報がありません"))); if (hasText && query == "Needle") QVERIFY(reader_->notice().contains(QStringLiteral("一致する箇所"))); if (hasText && query != "Needle") QVERIFY(reader_->notice().contains(QStringLiteral("検索結果がありません"))); // A new document must start with its own evidence, even after a // supported or unavailable capability was established previously. const auto token = session->token; reader_->openPath(manyPages_); QTRY_VERIFY_WITH_TIMEOUT(reader_->state() == "Ready" && reader_->webToken() != token, 15000); session = activeSession(); QVERIFY(session && session->worker); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000); QCOMPARE(capability(), "loading"); } void tinyPdfCommentRemainsKeyboardAccessible() { reader_->openPath(QFINDTESTDATA("fixtures/pdf/tiny-comments.pdf")); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); canvas_->setZoom(25); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QSignalSpy comments(canvas_, &PdfCanvas::annotationRequested); key(Qt::Key_Tab); key(Qt::Key_Return); QTRY_COMPARE(comments.size(), 1); QCOMPARE(comments.first().first().toString(), "Literal comment"); QVERIFY(std::isfinite(canvas_->offset())); QCOMPARE(canvas_->currentPage(), 0); key(Qt::Key_Escape); QTRY_COMPARE(reader_->mode(), "normal"); } void pdfCommentsAreReadableWithKeyboard_data() { QTest::addColumn("outside"); QTest::newRow("links-and-comment") << false; QTest::newRow("comments-only-outside-crop") << true; } void pdfCommentsAreReadableWithKeyboard() { QFETCH(bool, outside); const auto path = outside ? QFINDTESTDATA("fixtures/pdf/comments.pdf") : QFINDTESTDATA("fixtures/pdf/navigation.pdf"); const auto original = fileHash(path); QVERIFY(!original.isEmpty()); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QSignalSpy comments(canvas_, &PdfCanvas::annotationRequested); auto *dialog = window_->findChild("infoDialog"); auto *body = window_->findChild("infoText"); QVERIFY(dialog); QVERIFY(body); // Reverse tab starts at the last comment, after the unchanged link order. key(Qt::Key_Tab, Qt::ShiftModifier); key(Qt::Key_Return); QTRY_COMPARE_WITH_TIMEOUT(comments.size(), 1, 3000); QCOMPARE(body->property("text").toString(), outside ? "Outside CropBox comment" : "Read-only comment"); QCOMPARE(dialog->property("title").toString(), QString::fromUtf8("注釈")); QCOMPARE(body->property("textFormat").toInt(), 0); // Qt.PlainText; contents never become markup QCOMPARE(reader_->mode(), "dialog"); QVERIFY(dialog->property("visible").toBool()); const auto screenshots = qEnvironmentVariable("DOCVIEW_COMMENT_SCREENSHOTS"); if (!screenshots.isEmpty()) { QVERIFY(QDir().mkpath(screenshots)); QTest::qWait(100); QVERIFY(window_->grabWindow().save(QDir(screenshots).filePath(outside ? "outside-crop.png" : "comment.png"))); } key(Qt::Key_Escape); QTRY_COMPARE(reader_->mode(), "normal"); QCOMPARE(canvas_->currentPage(), 0); if (outside) { key(Qt::Key_Tab); key(Qt::Key_Return); QTRY_COMPARE(comments.size(), 2); QCOMPARE(body->property("text").toString(), "Literal comment"); key(Qt::Key_Escape); QTRY_COMPARE(reader_->mode(), "normal"); } else { // Forward tab wraps to the URI link and then to the blocked Launch // link, before arriving back at the comment. key(Qt::Key_Tab); key(Qt::Key_Tab); key(Qt::Key_Return); QVERIFY(reader_->notice().contains("E_LINK_BLOCKED")); QCOMPARE(comments.size(), 1); key(Qt::Key_Tab); key(Qt::Key_Return); QTRY_COMPARE(comments.size(), 2); key(Qt::Key_Escape); QTRY_COMPARE(reader_->mode(), "normal"); } // Rotations and zoom preserve the selected comment and its read-only body. for (int rotation : {90, 180, 270, 0}) { canvas_->goToLocation({{"pageIndex", 0}, {"viewRotation", rotation}, {"zoom", 200.0}, {"fitMode", "percent"}}); const int before = comments.size(); canvas_->activateLink(); QTRY_COMPARE(comments.size(), before + 1); key(Qt::Key_Escape); QTRY_COMPARE(reader_->mode(), "normal"); } key(Qt::Key_Escape); // In normal mode Esc clears the PDF target selection. const int beforeClear = comments.size(); key(Qt::Key_Return); QCOMPARE(comments.size(), beforeClear); QVERIFY(reader_->notice().contains(QString::fromUtf8("選択してください"))); QCOMPARE(fileHash(path), original); reader_->execute("document.info"); QCOMPARE(dialog->property("title").toString(), QString::fromUtf8("文書情報")); key(Qt::Key_Escape); QTRY_COMPARE(reader_->mode(), "normal"); reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); const int before = comments.size(); canvas_->activateLink(); QCOMPARE(comments.size(), before); // old PDF selection was retired } void htmlStyleHeadingsAndScriptIsolation() { reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QCOMPARE(reader_->format(), "html"); QTRY_VERIFY_WITH_TIMEOUT(reader_->outline().size() >= 2, 10000); QVERIFY(reader_->pages().isEmpty()); QVariant result; QVERIFY(evaluateWeb("({title:document.querySelector('h1').textContent,color:getComputedStyle(document.querySelector('h1')).color,executed:document.body.hasAttribute('data-document-script')})", &result)); QCOMPARE(result.toMap().value("title").toString(), "First heading"); QCOMPARE(result.toMap().value("color").toString(), "rgb(10, 20, 30)"); QCOMPARE(result.toMap().value("executed").toBool(), false); reader_->execute("nav.heading_next"); QTest::qWait(150); QVERIFY(evaluateWeb("document.querySelector('h1').getBoundingClientRect().top", &result)); QVERIFY(qAbs(result.toDouble()) < 3); reader_->execute("nav.heading_next"); QTest::qWait(150); QVERIFY(evaluateWeb("({scroll:window.scrollY,total:document.documentElement.scrollHeight,viewport:innerHeight,first:document.querySelector('h1').getBoundingClientRect().top,second:document.querySelector('h2').getBoundingClientRect().top,writing:getComputedStyle(document.body).writingMode})", &result)); QVERIFY2(result.toMap().value("scroll").toDouble() > 500 && qAbs(result.toMap().value("second").toDouble()) < 3, QJsonDocument::fromVariant(result).toJson(QJsonDocument::Compact).constData()); const auto token = reader_->webToken(); QVERIFY(!reader_->navigationAllowed(QUrl("https://example.org/"), 5, token)); QCOMPARE(reader_->webToken(), token); reader_->execute("nav.page", {{"page", 42}}); QVERIFY2(reader_->notice().contains("利用できません"), qPrintable(reader_->notice())); QCOMPARE(reader_->webToken(), token); } void zipRelativeStylesAndInternalNavigation() { const auto originalHash = fileHash(zip_); reader_->openPath(zip_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); QCOMPARE(reader_->format(), "htmlzip"); QTRY_VERIFY_WITH_TIMEOUT(reader_->outline().size() >= 2, 10000); QVariant result; QVERIFY(evaluateWeb("getComputedStyle(document.querySelector('h1')).color", &result)); QCOMPARE(result.toString(), "rgb(40, 50, 60)"); const auto token = reader_->webToken(); reader_->activateItem({{"href", "other.html#destination"}}); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("other.html"), 15000); QTRY_VERIFY_WITH_TIMEOUT(reader_->outline().size() == 1, 10000); QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &result)); QCOMPARE(result.toString(), "Archive linked destination"); QCOMPARE(reader_->webToken(), token); QCOMPARE(fileHash(zip_), originalHash); } void epubSpineOrderAndNonlinearSkipping() { reader_->openPath(epub_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); QCOMPARE(reader_->format(), "epub"); QCOMPARE(reader_->outline().first().toMap().value("title").toString(), "Second in contents"); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); QVariant result; QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &result)); QCOMPARE(result.toString(), "Chapter One"); reader_->execute("nav.chapter_next"); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("two.xhtml"), 15000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb()->property("documentReady").toBool(), 10000); QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &result)); QCOMPARE(result.toString(), "Chapter Two"); QTRY_VERIFY_WITH_TIMEOUT(reader_->position().contains("3 / 3"), 10000); reader_->execute("nav.chapter_previous"); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("one.xhtml"), 15000); } void resourceWarningsAggregateWithoutRetainingReferences_data() { QTest::addColumn("kind"); QTest::newRow("html") << QString("html"); QTest::newRow("zip") << QString("zip"); QTest::newRow("epub") << QString("epub"); } void resourceWarningsAggregateWithoutRetainingReferences() { QFETCH(QString, kind); const auto path = documents_.filePath("resource-warnings." + kind); const QByteArray html = "Resource checks" "" "" "

Readable despite missing resources

Local font check

" ""; if (kind == "html") QVERIFY(writeBytes(path, html)); else if (kind == "zip") QVERIFY(makeZip(path, {{"index.html", html}})); else { auto files = epubFixture(); for (auto &file : files) if (file.first == "OPS/one.xhtml") file.second = html; QVERIFY(makeZip(path, files)); } const auto original = fileHash(path); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); auto *session = activeSession(); QVERIFY(session); QTRY_VERIFY_WITH_TIMEOUT(session->resourceIssues.value("broker.missing") >= 3, 10000); QTRY_VERIFY_WITH_TIMEOUT(session->resourceIssues.value("csp.style") >= 1, 10000); QTRY_VERIFY_WITH_TIMEOUT(session->resourceIssues.value("csp.image") >= 1, 10000); const auto firstWarnings = session->meta.value("warnings").toStringList(); QCOMPARE(firstWarnings.filter(":info").size(), 1); const int missing = session->resourceIssues.value("broker.missing"); QVariant result; QVERIFY(evaluateWeb("(()=>{const i=document.createElementNS('http://www.w3.org/1999/xhtml','img');i.src='absent-late-image.png';document.body.append(i);return document.querySelector('h1').textContent})()", &result)); QCOMPARE(result.toString(), "Readable despite missing resources"); QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.missing"), missing + 1, 10000); QCOMPARE(session->meta.value("warnings").toStringList(), firstWarnings); reader_->notify(""); // Details outlive the transient notification. QSignalSpy information(reader_.get(), &Controller::uiCommand); reader_->execute("document.info"); QVERIFY(!information.isEmpty()); const auto text = information.last().at(1).toMap().value("text").toString(); QVERIFY(text.contains("E_RESOURCE_MISSING")); QVERIFY(text.contains("E_RESOURCE_BLOCKED")); QVERIFY(text.contains(QString::number(missing + 1) + "件")); QVERIFY(text.contains("スタイルシート")); QVERIFY(!text.contains("secret")); QVERIFY(!text.contains("invalid.example")); QVERIFY(!text.contains(session->token)); QVERIFY(!text.contains(documents_.path())); QCOMPARE(fileHash(path), original); const auto output = qEnvironmentVariable("DOCVIEW_RESOURCE_SCREENSHOT"); if (!output.isEmpty() && kind == "html") { QTest::qWait(200); QVERIFY(window_->grabWindow().save(output)); } } void resourceFailureReasonsStayDistinct() { const auto root = documents_.filePath("resource-classification"); QVERIFY(QDir().mkpath(root)); const auto page = root + "/index.html"; QVERIFY(writeBytes(page, "

Readable resource classes

")); QVERIFY(writeBytes(root + "/unsupported.bin", "body{}")); QVERIFY(writeBytes(root + "/denied.css", "body{}")); QFile large(root + "/large.css"); QVERIFY(large.open(QIODevice::WriteOnly)); QVERIFY(large.resize(256ll * 1024 * 1024 + 1)); large.close(); #ifdef Q_OS_LINUX QVERIFY(::symlink("denied.css", QFile::encodeName(root + "/symbolic.css").constData()) == 0); QVERIFY(::link(QFile::encodeName(root + "/denied.css").constData(), QFile::encodeName(root + "/hard.css").constData()) == 0); QVERIFY(writeBytes(root + "/private.css", "body{}")); QVERIFY(QFile::setPermissions(root + "/private.css", {})); #endif reader_->openPath(page); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY(activeWeb() && activeWeb()->property("documentReady").toBool()); auto *session = activeSession(); QVERIFY(session); QStringList files{"absent.css", "unsupported.bin", "large.css"}; #ifdef Q_OS_LINUX files << "symbolic.css" << "hard.css"; if (::geteuid() != 0) files << "private.css"; #endif for (const auto &name : files) { QVariant result; QVERIFY(evaluateWeb("(()=>{const e=document.createElement('link');e.rel='stylesheet';e.href='" + name + "';document.head.append(e);return true})()", &result)); } QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.missing"), 1, 10000); QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.type"), 1, 10000); QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.limit"), 1, 10000); #ifdef Q_OS_LINUX QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.blocked"), 2, 10000); if (::geteuid() != 0) QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.denied"), 1, 10000); QVERIFY(QFile::setPermissions(root + "/private.css", QFile::ReadOwner | QFile::WriteOwner)); #endif QSignalSpy info(reader_.get(), &Controller::uiCommand); reader_->execute("document.info"); const auto text = info.last().at(1).toMap().value("text").toString(); QVERIFY(text.contains("E_RESOURCE_MISSING")); QVERIFY(text.contains("E_RESOURCE_BLOCKED")); QVERIFY(text.contains("E_RESOURCE_LIMIT")); QVERIFY(!text.contains(root)); QVERIFY(!text.contains("symbolic.css")); QVERIFY(!text.contains(session->token)); QCOMPARE(reader_->state(), "Ready"); } void archiveResourceFailureKeepsItsReason_data() { QTest::addColumn("kind"); QTest::addColumn("reason"); QTest::addColumn("code"); QTest::newRow("actual-crc") << QString("crc") << QString("broker.corrupt") << QString("E_ARCHIVE_CORRUPT"); QTest::newRow("expanded-budget") << QString("limit") << QString("broker.archive_limit") << QString("E_ARCHIVE_LIMIT"); QTest::newRow("unavailable-storage") << QString("storage") << QString("broker.storage_failed") << QString("E_STORAGE_FAILED"); } void archiveResourceFailureKeepsItsReason() { QFETCH(QString, kind); QFETCH(QString, reason); QFETCH(QString, code); const auto archive = documents_.filePath("classified-" + kind + ".zip"); const QByteArray asset = "body{color:rgb(12,34,56)}/*unique-resource-crc*/"; QVERIFY(makeZip(archive, {{"index.html", "

Still readable

"}, {"late.css", asset}})); if (kind == "crc") { QFile file(archive); QVERIFY(file.open(QIODevice::ReadWrite)); auto bytes = file.readAll(); const auto offset = bytes.indexOf(asset); QVERIFY(offset >= 0); bytes[offset] = 'X'; QVERIFY(file.seek(0)); QCOMPARE(file.write(bytes), bytes.size()); file.close(); } const auto original = fileHash(archive); reader_->openPath(archive); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY(activeWeb() && activeWeb()->property("documentReady").toBool()); auto *session = activeSession(); QVERIFY(session && session->worker); const auto resourceRoot = session->root; if (kind == "limit") session->expandedBytes = 2ll * 1024 * 1024 * 1024 - 1; if (kind == "storage") session->root += "/unavailable-storage"; QVariant value; QVERIFY(evaluateWeb("(()=>{const e=document.createElement('link');e.rel='stylesheet';e.href='late.css';document.head.append(e);return true})()", &value)); QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value(reason), 1, 10000); QCOMPARE(session->resourceIssues.value("broker.missing"), 0); QVERIFY(!QFile::exists(resourceRoot + "/late.css")); if (kind != "storage") { QCOMPARE(reader_->state(), "Recovering"); QVERIFY(session->resourcesStopped); } QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &value)); QCOMPARE(value.toString(), "Still readable"); QSignalSpy info(reader_.get(), &Controller::uiCommand); reader_->execute("document.info"); const auto text = info.last().at(1).toMap().value("text").toString(); QVERIFY(text.contains(code)); QVERIFY(!text.contains("E_RESOURCE_MISSING")); QVERIFY(!text.contains("late.css")); QVERIFY(!text.contains(resourceRoot)); QCOMPARE(fileHash(archive), original); session->root = resourceRoot; } #ifdef Q_OS_LINUX void pendingArchiveWorkerFailure_data() { QTest::addColumn("kind"); QTest::newRow("real-killed-worker") << QString("crash"); QTest::newRow("timeout-signal") << QString("timeout"); QTest::newRow("explicit-document-replacement") << QString("replace"); } void pendingArchiveWorkerFailure() { QFETCH(QString, kind); const auto path = documents_.filePath("pending-worker-" + kind + ".zip"); QVERIFY(makeZip(path, {{"index.html", "

Preserved content

"}, {"late.css", "body{color:black}"}})); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY(activeWeb() && activeWeb()->property("documentReady").toBool()); QPointer session = activeSession(); QVERIFY(session && session->worker); auto *socket = session->worker->findChild(); QVERIFY(socket); struct ucred peer{}; socklen_t length = sizeof(peer); QCOMPARE(::getsockopt(int(socket->socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &peer, &length), 0); QVERIFY(peer.pid > 1 && peer.pid != ::getpid()); QCOMPARE(peer.uid, ::geteuid()); QCOMPARE(::kill(peer.pid, SIGSTOP), 0); bool completed = false; const auto cleanup = qScopeGuard([&] { if (!completed) ::kill(peer.pid, SIGKILL); }); QVariant value; QVERIFY(evaluateWeb("(()=>{const e=document.createElement('link');e.rel='stylesheet';e.href='late.css';document.head.append(e);return true})()", &value)); QTRY_VERIFY_WITH_TIMEOUT(session->extractWaiters.contains("late.css"), 5000); auto outcomes = std::make_shared>(); session->extractWaiters["late.css"].append([outcomes](ResourceFailure failure) { *outcomes << failure; }); QSignalSpy issues(session->handler, &ResourceHandler::blocked); if (kind == "replace") { reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "html", 10000); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000); QCOMPARE(*outcomes, QList{ResourceFailure::Cancelled}); QCOMPARE(issues.size(), 0); } else { if (kind == "crash") QCOMPARE(::kill(peer.pid, SIGKILL), 0); else QVERIFY(QMetaObject::invokeMethod(session->worker, "failed", Qt::DirectConnection, Q_ARG(QString, "E_WORKER_TIMEOUT"), Q_ARG(QString, "Injected deadline notification"))); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Recovering", 10000); const auto failure = kind == "crash" ? ResourceFailure::WorkerFailed : ResourceFailure::WorkerTimeout; QCOMPARE(*outcomes, QList{failure}); QCOMPARE(session->resourceIssues.value(resourceFailureKey(failure)), 1); QCOMPARE(session->resourceIssues.value("broker.missing"), 0); QVERIFY(session->extractWaiters.isEmpty()); QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &value)); QCOMPARE(value.toString(), "Preserved content"); QTest::qWait(50); QCOMPARE(outcomes->size(), 1); QCOMPARE(issues.size(), 1); } completed = true; } #endif void resourceWarningReportsValidateWholeBatchAndSession() { reader_->clearHistory(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n")); reader_->execute("config.reload"); reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); auto *session = activeSession(); QVERIFY(session); const auto token = session->token; reader_->webResourceIssues(token, {{"csp.image", 3}, {"csp.font", 2.0}}); const auto counts = session->resourceIssues; const QList invalid{ {{"csp.image", 4}, {"csp.url", "https://secret.invalid/"}}, {{"csp.image", true}}, {{"csp.image", "3"}}, {{"csp.image", 0}}, {{"csp.image", -1}}, {{"csp.image", 1.5}}, {{"csp.image", 1000000}}, {{"csp.image", std::numeric_limits::infinity()}}, {{"csp.image", std::numeric_limits::quiet_NaN()}}, {{"broker.missing", 10}}, {{"network.blocked", 10}}}; for (const auto &report : invalid) { reader_->webResourceIssues(token, report); QCOMPARE(session->resourceIssues, counts); } reader_->webResourceIssues("stale-document", {{"csp.image", 7}}); QCOMPARE(session->resourceIssues, counts); reader_->webResourceIssues(token, {{"csp.image", 999999}}); QCOMPARE(session->resourceIssues.value("csp.image"), 999999); reader_->webResourceIssues(token, {{"csp.image", 999999}}); QCOMPARE(session->resourceIssues.value("csp.image"), 999999); for (const auto &kind : {"style", "font", "script", "frame", "connect", "form", "base", "object", "media", "other"}) reader_->webResourceIssues(token, {{"csp." + QString::fromLatin1(kind), 1}}); window_->resize(480, 360); reader_->execute("document.info"); auto *dialog = window_->findChild("infoDialog"); auto *scroll = window_->findChild("infoScroll"); QVERIFY(dialog && scroll); QTRY_VERIFY(dialog->property("visible").toBool()); QVERIFY(dialog->property("height").toDouble() <= 300); QVERIFY(scroll->property("contentHeight").toDouble() > scroll->property("availableHeight").toDouble()); key(Qt::Key_Escape); QTRY_VERIFY(!dialog->property("visible").toBool()); QCOMPARE(reader_->mode(), "normal"); auto *store = reader_->findChild(); QVERIFY(store && store->flush()); QFile state(testStorage().stateFile); QVERIFY(state.open(QIODevice::ReadOnly)); const auto saved = state.readAll(); QVERIFY(!saved.contains("csp.image")); QVERIFY(!saved.contains("resourceIssues")); QVERIFY(!saved.contains("secret.invalid")); reader_->openPath(documents_.filePath("other.html")); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY_WITH_TIMEOUT(reader_->webToken() != token, 10000); auto *next = activeSession(); QVERIFY(next); const auto nextCounts = next->resourceIssues; reader_->webResourceIssues(token, {{"csp.image", 100}}); QCOMPARE(next->resourceIssues, nextCounts); } void webLocationPersistsOnlyValidatedLogicalFields() { QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\nstore_text_anchor=false\n")); reader_->execute("config.reload"); reader_->clearHistory(); reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); auto *session = activeSession(); QVERIFY(session); const auto token = reader_->webToken(); QVariantMap valid{{"url", "doc://" + token + "/index.html"}, {"cfi", "epubcfi(/4/2/1:0)"}, {"fragment", "first"}, {"progression", .25}, {"offsetX", .1}, {"offsetY", .2}, {"textQuote", "short visible text"}, {"arbitrary", QVariantMap{{"token", token}}}}; QSignalSpy positioned(reader_.get(), &Controller::positionChanged); reader_->webLocation(token, valid); QCOMPARE(positioned.size(), 1); QVERIFY(QMetaObject::invokeMethod(window_, "frameSwapped", Qt::DirectConnection)); QCoreApplication::processEvents(); QCOMPARE(session->lastPresented.value("resourcePath").toString(), "index.html"); QVERIFY(!session->lastPresented.contains("url")); QVERIFY(!session->lastPresented.contains("arbitrary")); auto *store = reader_->findChild(); QVERIFY(store); QVERIFY(store->flush()); const auto saved = store->restore(html_).value("location").toMap(); QCOMPARE(saved.value("resourcePath").toString(), "index.html"); QVERIFY(!saved.contains("textQuote")); QFile state(testStorage().stateFile); QVERIFY(state.open(QIODevice::ReadOnly)); const auto bytes = state.readAll(); QVERIFY(!bytes.contains(token.toUtf8())); QVERIFY(!bytes.contains("doc://")); // Wrong types, limits, origin and old generations cannot replace the // last validated position; unknown data is never serialized. QList bad; auto changed = [&](const QString &key, const QVariant &value) { auto next = valid; next[key] = value; bad.append(next); }; changed("progression", "0.5"); changed("progression", true); changed("progression", -0.1); changed("progression", 1.1); changed("progression", std::numeric_limits::quiet_NaN()); changed("cfi", QVariantList{1, 2}); changed("cfi", QString(4097, 'a')); changed("fragment", QString(1025, 'a')); changed("textQuote", QString(161, 'a')); changed("offsetX", std::numeric_limits::infinity()); changed("url", "doc://different-session/index.html"); changed("url", "file:///etc/passwd"); changed("url", "doc://" + token + "/%2e%2e/secret.html"); for (const auto &value : bad) { positioned.clear(); reader_->webLocation(token, value); QCOMPARE(positioned.size(), 0); QVERIFY(reader_->notice().startsWith("E_RESOURCE_BLOCKED")); } positioned.clear(); reader_->webLocation("old-session", valid); QCOMPARE(positioned.size(), 0); } void epubRestoresStableSpineIdentityAndRejectsForeignPackage() { QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n")); reader_->execute("config.reload"); reader_->clearHistory(); auto *store = reader_->findChild(); QVERIFY(store); // The stable ID wins over an outdated href. CFI stays local to the // resolved spine resource; a legacy transient URL is discarded. const QVariantMap location{{"packagePath", "OPS/book.opf"}, {"spineIdref", "two"}, {"href", "OPS/one.xhtml"}, {"url", "doc://expired/OPS/one.xhtml"}, {"fragment", "two"}, {"progression", 0.0}}; QVERIFY(store->remember(epub_, location)); QSignalSpy commands(reader_.get(), &Controller::webCommand); reader_->openPath(epub_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("two.xhtml") && activeWeb()->property("documentReady").toBool(), 15000); QTRY_COMPARE_WITH_TIMEOUT(activeSession()->lastPresented.value("spineIdref").toString(), "two", 10000); QCOMPARE(activeSession()->lastPresented.value("packagePath").toString(), "OPS/book.opf"); QCOMPARE(activeSession()->lastPresented.value("resourcePath").toString(), "OPS/two.xhtml"); QVERIFY(!activeSession()->lastPresented.contains("url")); bool restored = false; for (const auto &call : commands) if (call[0].toString() == "location.restore") { restored = true; const auto applied = call[1].toMap(); QCOMPARE(applied.value("spineIdref").toString(), "two"); QVERIFY(applied.value("href").toString().startsWith("OPS/two.xhtml")); QVERIFY(!applied.contains("url")); } QVERIFY(restored); // A different package or unresolved spine must not silently use href. reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "html", 20000); auto invalid = location; invalid["packagePath"] = "OPS/foreign.opf"; QVERIFY(store->remember(epub_, invalid)); commands.clear(); reader_->openPath(epub_); QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "epub", 20000); QTRY_VERIFY_WITH_TIMEOUT(reader_->notice().startsWith("E_LOCATOR_UNRESOLVED"), 10000); QCOMPARE(activeWeb()->property("url").toUrl().path(), "/OPS/one.xhtml"); for (const auto &call : commands) QVERIFY(call[0].toString() != "location.restore"); } void legacyHtmlLocatorRestoresWithoutSessionUrl_data() { QTest::addColumn("resourceOnly"); QTest::newRow("legacy-href") << false; QTest::newRow("logical-resource-path") << true; } void legacyHtmlLocatorRestoresWithoutSessionUrl() { QFETCH(bool, resourceOnly); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n")); reader_->execute("config.reload"); reader_->clearHistory(); auto *store = reader_->findChild(); QVERIFY(store); QVariantMap savedLocation{{"href", "other.html#destination"}, {"url", "doc://expired-capability/other.html#destination"}, {"fragment", "destination"}, {"progression", 0.0}}; if (resourceOnly) { savedLocation.remove("href"); savedLocation.remove("url"); savedLocation["resourcePath"] = "other.html"; } QVERIFY(store->remember(html_, savedLocation)); reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("other.html") && activeWeb()->property("documentReady").toBool(), 15000); QTRY_COMPARE_WITH_TIMEOUT(activeSession()->lastPresented.value("resourcePath").toString(), "other.html", 10000); QVERIFY(store->flush()); const auto saved = store->restore(html_).value("location").toMap(); QVERIFY(!saved.contains("url")); QCOMPARE(saved.value("resourcePath").toString(), "other.html"); } void failedReplacementCancelAndStaleGeneration() { reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); reader_->execute("nav.page", {{"page", 42}}); QTRY_COMPARE(canvas_->currentPage(), 41); const auto title = reader_->title(); reader_->openPath(QFINDTESTDATA("fixtures/pdf/corrupt.pdf")); QCOMPARE(reader_->state(), "Opening"); QCOMPARE(reader_->title(), title); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QCOMPARE(reader_->title(), title); QCOMPARE(canvas_->currentPage(), 41); QVERIFY(reader_->notice().startsWith("E_")); reader_->openPath(epub_); QCOMPARE(reader_->state(), "Opening"); reader_->cancel(); QCOMPARE(reader_->state(), "Ready"); QVERIFY(reader_->notice().contains("取り消しました")); QTest::qWait(200); QCOMPARE(reader_->title(), title); QCOMPARE(canvas_->currentPage(), 41); reader_->openPath(epub_); reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QCOMPARE(reader_->format(), "html"); const auto token = reader_->webToken(); reader_->webLoaded("obsolete-generation", true); reader_->webIndex("obsolete-generation", {{"headings", QVariantList{QVariantMap{{"title", "stale"}}}}}); QCOMPARE(reader_->webToken(), token); QTest::qWait(200); QCOMPARE(reader_->format(), "html"); } void epubDocumentEndpoints_data() { QTest::addColumn("writingMode"); QTest::addColumn("direction"); QTest::newRow("horizontal-ltr") << "horizontal-tb" << "ltr"; QTest::newRow("horizontal-rtl") << "horizontal-tb" << "rtl"; QTest::newRow("vertical-rl") << "vertical-rl" << "rtl"; } void epubDocumentEndpoints() { QFETCH(QString, writingMode); QFETCH(QString, direction); auto files = epubFixture(); files[2].second.replace("", ""); files[2].second.replace("", ""); files[2].second.replace("", ""); const auto style = QString("") .arg(writingMode, direction, writingMode == "vertical-rl" ? "min-width:4000px" : "min-height:4000px").toUtf8(); files[4].second.replace("", style); files[6].second.replace("", style); files.append(QPair{"OPS/pre.xhtml", "

Nonlinear front

"}); files.append(QPair{"OPS/post.xhtml", "

Nonlinear end

"}); const auto path = documents_.filePath("endpoints-" + writingMode + "-" + direction + ".epub"); QVERIFY(makeZip(path, files)); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); const auto at = [&](const QString &name, double expected) { if (!activeWeb() || !activeWeb()->property("documentReady").toBool() || !activeWeb()->property("url").toUrl().path().endsWith(name)) return false; QVariant result; return evaluateWeb("__docviewReader.location().progression", &result) && std::abs(result.toDouble() - expected) < .002; }; // The first/last spine rows are nonlinear; document endpoints belong // to normal reading order and include the final chapter's actual end. QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 0), 10000); reader_->execute("nav.document_end"); QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 1), 10000); reader_->execute("nav.document_start"); QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 0), 10000); // No load event is required when already in the endpoint resource. QVariant result; QVERIFY(evaluateWeb("__docviewReader.command('nav.document_end',{})", &result)); QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 1), 5000); reader_->execute("nav.document_start"); QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 0), 5000); reader_->execute("nav.document_end"); QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 1), 10000); QVERIFY(evaluateWeb("__docviewReader.command('nav.document_start',{})", &result)); QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 0), 5000); reader_->execute("nav.document_end"); QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 1), 5000); } void zipEntryChoiceSurvivesRestart_data() { QTest::addColumn("rememberPosition"); QTest::newRow("recent-history-without-reading-position") << false; QTest::newRow("reading-position-without-recent-list") << true; } void zipEntryChoiceSurvivesRestart() { QFETCH(bool, rememberPosition); reader_->clearHistory(); const auto privacy = rememberPosition ? QByteArray("[privacy]\nremember_position=true\nrecent_documents=0\n") : QByteArray("[privacy]\nremember_position=false\nrecent_documents=3\n"); QVERIFY(writeBytes(configuration_, privacy)); reader_->execute("config.reload"); const auto path = documents_.filePath("remember-entry.zip"); QVERIFY(makeZip(path, {{"a/first.html", "

First entry

"}, {"b/second.html", "

Chosen entry

"}})); const auto original = fileHash(path); QSignalSpy choices(reader_.get(), &Controller::entryNeeded); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000); QCOMPARE(reader_->mode(), "dialog"); auto *list = window_->findChild("entryCandidates"); QVERIFY(list); QCOMPARE(list->property("currentIndex").toInt(), 0); key(Qt::Key_Down); QCOMPARE(list->property("currentIndex").toInt(), 1); key(Qt::Key_Return); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); QVERIFY(activeWeb()->property("url").toUrl().path().endsWith("b/second.html")); auto *store = reader_->findChild(); QVERIFY(store); const QStringList candidates{"a/first.html", "b/second.html"}; QCOMPARE(store->archiveEntry(path, candidates), "b/second.html"); if (!rememberPosition) QVERIFY(store->restore(path).isEmpty()); QVERIFY(store->flush()); cleanup(); QProcess freshProcess; freshProcess.setProcessChannelMode(QProcess::MergedChannels); freshProcess.start(QCoreApplication::applicationFilePath(), {"--zip-entry-probe", documents_.path()}); QVERIFY(freshProcess.waitForStarted(5000)); QVERIFY(freshProcess.waitForFinished(30000)); QVERIFY2(freshProcess.exitStatus() == QProcess::NormalExit && freshProcess.exitCode() == 0, freshProcess.readAll().constData()); startUi(); QSignalSpy reopened(reader_.get(), &Controller::entryNeeded); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); QCOMPARE(reopened.size(), 0); QCOMPARE(reader_->mode(), "normal"); QVERIFY(activeWeb()->property("url").toUrl().path().endsWith("b/second.html")); cleanup(); const auto savedHash = fileHash(testStorage().stateFile); startUi(true); QVERIFY(reader_->findChild()->readOnly()); QSignalSpy readOnlyChoices(reader_.get(), &Controller::entryNeeded); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QCOMPARE(readOnlyChoices.size(), 0); QVERIFY(activeWeb()->property("url").toUrl().path().endsWith("b/second.html")); cleanup(); QCOMPARE(fileHash(testStorage().stateFile), savedHash); QCOMPARE(fileHash(path), original); } void zipEntryChoiceDisabledOrCleared_data() { QTest::addColumn("disabled"); QTest::newRow("fully-disabled-privacy") << true; QTest::newRow("history-cleared") << false; } void zipEntryChoiceDisabledOrCleared() { QFETCH(bool, disabled); reader_->clearHistory(); QVERIFY(writeBytes(configuration_, disabled ? "[privacy]\nremember_position=false\nrecent_documents=0\n" : "[privacy]\nremember_position=false\nrecent_documents=3\n")); reader_->execute("config.reload"); const auto path = documents_.filePath("private-entry.zip"); QVERIFY(makeZip(path, {{"one.html", "

One

"}, {"two.html", "

Two

"}})); QSignalSpy choices(reader_.get(), &Controller::entryNeeded); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000); key(Qt::Key_Down); key(Qt::Key_Return); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QTRY_VERIFY_WITH_TIMEOUT(activeSession() && !activeSession()->lastPresented.isEmpty(), 10000); if (!disabled) { QCOMPARE(reader_->findChild()->archiveEntry(path, {"one.html", "two.html"}), "two.html"); reader_->clearHistory(); } QCOMPARE(reader_->historyCount(), 0); cleanup(); startUi(); QSignalSpy reopened(reader_.get(), &Controller::entryNeeded); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reopened.size(), 1, 10000); QCOMPARE(reader_->state(), "Opening"); QVERIFY(reader_->webToken().isEmpty()); key(Qt::Key_Escape); QTRY_COMPARE(reader_->state(), "Empty"); QCOMPARE(reader_->historyCount(), 0); } void zipEntryChoiceInvalidated_data() { QTest::addColumn("change"); QTest::newRow("replacement-same-bytes-size-mtime") << "replacement"; QTest::newRow("archive-candidate-removed") << "changed"; QTest::newRow("stored-candidate-no-longer-present") << "missing"; QTest::newRow("unsafe-saved-candidate") << "unsafe"; } void zipEntryChoiceInvalidated() { QFETCH(QString, change); reader_->clearHistory(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=3\n")); reader_->execute("config.reload"); const auto path = documents_.filePath("changed-entry-" + change + ".zip"); QVERIFY(makeZip(path, {{"first.html", "

First

"}, {"second.html", "

Second

"}})); const auto identity = StateStore::fileIdentity(path); const auto originalHash = fileHash(path); QSignalSpy choices(reader_.get(), &Controller::entryNeeded); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000); key(Qt::Key_Down); key(Qt::Key_Return); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); QCOMPARE(reader_->findChild()->archiveEntry(path, {"first.html", "second.html"}), "second.html"); cleanup(); if (change == "replacement") { QVERIFY(QFile::rename(path, path + ".original")); QVERIFY(QFile::copy(path + ".original", path)); QFile replaced(path); QVERIFY(replaced.open(QIODevice::ReadWrite)); QVERIFY(replaced.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime)); replaced.close(); const auto current = StateStore::fileIdentity(path); QCOMPARE(current.value("size"), identity.value("size")); QCOMPARE(current.value("mtime"), identity.value("mtime")); QVERIFY(current.value("fileIdentity") != identity.value("fileIdentity")); QCOMPARE(fileHash(path), originalHash); } else if (change == "changed") { QVERIFY(QFile::remove(path)); QVERIFY(makeZip(path, {{"first.html", "

Changed original

"}, {"third.html", "

New candidate

"}})); } else { QFile state(testStorage().stateFile); QVERIFY(state.open(QIODevice::ReadOnly)); auto json = QJsonDocument::fromJson(state.readAll()).object(); state.close(); auto entries = json.value("documents").toArray(); QCOMPARE(entries.size(), 1); auto record = entries.first().toObject(); record["archiveEntry"] = change == "unsafe" ? "../second.html" : "absent.html"; entries[0] = record; json["documents"] = entries; QVERIFY(writeBytes(testStorage().stateFile, QJsonDocument(json).toJson())); } startUi(); QSignalSpy reopened(reader_.get(), &Controller::entryNeeded); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reopened.size(), 1, 10000); QCOMPARE(reader_->state(), "Opening"); QVERIFY(reader_->webToken().isEmpty()); const auto candidates = reopened.first().first().toStringList(); QVERIFY(candidates.contains("first.html")); QVERIFY(!candidates.contains("../second.html")); key(Qt::Key_Escape); QTRY_COMPARE(reader_->state(), "Empty"); if (change != "changed") QCOMPARE(fileHash(path), originalHash); } void cancelledZipEntryChoiceIsNotSaved() { reader_->clearHistory(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=3\n")); reader_->execute("config.reload"); const auto path = documents_.filePath("cancelled-choice.zip"); QVERIFY(makeZip(path, {{"first.html", "

First

"}, {"second.html", "

Second

"}})); QSignalSpy choices(reader_.get(), &Controller::entryNeeded); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000); auto *dialog = window_->findChild("entryDialog"); QVERIFY(dialog); QVERIFY(QMetaObject::invokeMethod(dialog, "accept")); reader_->cancel(); // No event loop turn: the selected document has not committed. QCOMPARE(reader_->state(), "Empty"); QCOMPARE(reader_->historyCount(), 0); QVERIFY(reader_->findChild()->archiveEntry(path, {"first.html", "second.html"}).isEmpty()); QTest::qWait(100); QCOMPARE(reader_->historyCount(), 0); cleanup(); startUi(); QSignalSpy reopened(reader_.get(), &Controller::entryNeeded); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reopened.size(), 1, 10000); key(Qt::Key_Escape); } void epubFixedSpreads_data() { QTest::addColumn("policy"); QTest::addColumn("rtl"); QTest::newRow("both-ltr") << "both" << false; QTest::newRow("both-rtl") << "both" << true; QTest::newRow("landscape") << "landscape" << false; QTest::newRow("auto") << "auto" << false; QTest::newRow("none") << "none" << false; } void epubFixedSpreads() { QFETCH(QString, policy); QFETCH(bool, rtl); auto files = epubFixture(); files = files.mid(0, 2); const QStringList sides = rtl ? QStringList{"right", "left"} : QStringList{"left", "right"}; QByteArray package = "Spread proofpre-paginated" + policy.toUtf8() + ""; QByteArray spine; for (int i = 1; i <= 7; ++i) { const auto number = QByteArray::number(i); package += ""; QByteArray properties; if (i <= 2) properties = "page-spread-" + sides[i-1].toUtf8(); if (i == 3) properties = "rendition:page-spread-center rendition:spread-both"; if (i == 4) properties = "rendition:spread-none"; if (i == 5) properties = "rendition:layout-reflowable"; if (i >= 6) properties = "rendition:spread-both page-spread-" + sides[i-6].toUtf8(); spine += ""; const QByteArray color = i % 2 ? "#e7edff" : "#fff0d4"; const QByteArray label = i == 3 ? "CENTER" : i == 4 ? "SPREAD NONE" : i == 5 ? "REFLOW" : "PAGE " + number; files.append({"OPS/p" + number + ".xhtml", ""}); } package += "" + spine + ""; files.append({"OPS/book.opf", package}); const auto path = documents_.filePath("spread-" + policy + (rtl ? "-rtl" : "-ltr") + ".epub"); QVERIFY(makeZip(path, files)); QVERIFY(resizeTestWindow(QSize(1100, 760))); QTRY_COMPARE_WITH_TIMEOUT(window_->size(), QSize(1100, 760), 5000); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); const auto companion = [&]() -> QQuickItem * { for (auto *object : window_->findChildren()) if (object->property("documentToken").toString() == reader_->webToken() && object->property("isCompanion").toBool() && object->property("alive").toBool()) return object; return nullptr; }; const auto loadedPage = [&](int number) { return activeWeb() && activeWeb()->property("documentReady").toBool() && activeWeb()->property("url").toUrl().path().endsWith(QString("p%1.xhtml").arg(number)); }; const auto readyPair = [&]() { return companion() && companion()->property("documentReady").toBool() && companion()->property("fixedViewport").toMap().value("width").toInt() == 400; }; const auto spreadDiagnostic = [&](const char *phase) { if (!qEnvironmentVariableIsSet("DOCVIEW_SPREAD_DIAGNOSTICS") || !activeWeb()) return; auto *pane = activeWeb()->parent(); QQmlExpression expression(engine_->rootContext(), pane, "JSON.stringify({width:width,height:height,selection:spreadSelection,failed:failedCompanionUrl," "pending:!!pendingView,activeReady:activeView.documentReady,fixed:activeView.fixedLayout," "active:resourcePath(activeView.url),companion:companionView?resourcePath(companionView.url):null," "companionReady:companionView?companionView.documentReady:false,options:optionsFor(activeView)})"); qInfo().noquote() << "Spread diagnostic" << phase << "window" << window_->size() << expression.evaluate().toString(); }; const auto diagnosticAtExit = qScopeGuard([&] { spreadDiagnostic("exit"); }); const auto verifyGeometry = [&]() { auto *primary = qobject_cast(activeWeb()); auto *other = companion(); if (!primary || !other) return false; auto *left = primary->x() < other->x() ? primary : other; auto *right = left == primary ? other : primary; return std::abs(left->x() + left->width() * left->scale() - right->x()) < 1.1 && std::abs(primary->height() * primary->scale() - other->height() * other->scale()) < 1.1; }; QTRY_VERIFY_WITH_TIMEOUT(loadedPage(1), 10000); if (policy != "none") { QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 15000); QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); QCOMPARE(companion()->property("url").toUrl().path(), "/OPS/p2.xhtml"); QCOMPARE(qobject_cast(activeWeb())->x() > companion()->x(), rtl); QVariant viewport; QVERIFY(evaluateWeb("[innerWidth,innerHeight]", &viewport)); QCOMPARE(viewport.toList().at(0).toInt(), 400); QCOMPARE(viewport.toList().at(1).toInt(), 600); } else QTRY_VERIFY_WITH_TIMEOUT(!companion(), 3000); const auto capture = [&](const QString &suffix) { const auto directory = qEnvironmentVariable("DOCVIEW_SPREAD_SCREENSHOTS"); if (directory.isEmpty()) return true; if (!QDir().mkpath(directory)) return false; QTest::qWait(150); return window_->grabWindow().save(QDir(directory).filePath(policy + (rtl ? "-rtl-" : "-ltr-") + suffix + ".png")); }; QVERIFY(capture("landscape")); if (policy == "both") { // Zoom scales the complete pair and physical keys can pan it. reader_->execute("zoom.in"); reader_->execute("zoom.in"); reader_->execute("zoom.in"); QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); auto *primary = qobject_cast(activeWeb()); const auto beforePan = primary->x(); reader_->execute("scroll.right"); QTRY_VERIFY_WITH_TIMEOUT(primary->x() < beforePan, 5000); reader_->execute("zoom.fit_width"); QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); // Cancelling a staged replacement reconstructs the prior spread. const auto token = reader_->webToken(); QSignalSpy staged(reader_.get(), &Controller::stageWeb); connect(reader_.get(), &Controller::stageWeb, reader_.get(), &Controller::cancel, Qt::ConnectionType(Qt::QueuedConnection | Qt::SingleShotConnection)); reader_->openPath(epub_); QTRY_COMPARE_WITH_TIMEOUT(staged.count(), 1, 10000); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000); QCOMPARE(reader_->webToken(), token); QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); QVERIFY(!reader_->notice().contains("読込中")); } QVERIFY(resizeTestWindow(QSize(600, 960))); QTRY_COMPARE_WITH_TIMEOUT(window_->size(), QSize(600, 960), 5000); QTRY_VERIFY(qobject_cast(activeWeb())->parentItem()->width() < qobject_cast(activeWeb())->parentItem()->height()); if (policy == "both") { QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); } else QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000); QVERIFY(capture("portrait")); QVERIFY(resizeTestWindow(QSize(1100, 760))); QTRY_COMPARE_WITH_TIMEOUT(window_->size(), QSize(1100, 760), 5000); QTRY_VERIFY(qobject_cast(activeWeb())->parentItem()->width() > qobject_cast(activeWeb())->parentItem()->height()); if (qEnvironmentVariableIsSet("DOCVIEW_SPREAD_DIAGNOSTICS")) { spreadDiagnostic("landscape-immediate"); } if (policy != "none") QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); reader_->execute("nav.chapter_next"); QTRY_VERIFY_WITH_TIMEOUT(loadedPage(2), 10000); if (policy != "none") { QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); QCOMPARE(companion()->property("url").toUrl().path(), "/OPS/p1.xhtml"); } reader_->execute("nav.chapter_next"); QTRY_VERIFY_WITH_TIMEOUT(loadedPage(3), 10000); QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000); QVERIFY(capture("center")); reader_->execute("nav.chapter_next"); QTRY_VERIFY_WITH_TIMEOUT(loadedPage(4), 10000); QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000); reader_->execute("nav.chapter_next"); QTRY_VERIFY_WITH_TIMEOUT(loadedPage(5), 10000); QTRY_COMPARE_WITH_TIMEOUT(activeWeb()->property("fixedLayout").toBool(), false, 5000); QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000); auto *reflow = qobject_cast(activeWeb()); QTRY_VERIFY(std::abs(reflow->width() - reflow->parentItem()->width()) < 1); reader_->execute("nav.chapter_next"); QTRY_VERIFY_WITH_TIMEOUT(loadedPage(6), 10000); QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); QCOMPARE(companion()->property("url").toUrl().path(), "/OPS/p7.xhtml"); // Only the commanded spine item becomes the logical position. QTRY_COMPARE_WITH_TIMEOUT(activeSession()->lastPresented.value("resourcePath").toString(), "OPS/p6.xhtml", 10000); reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "html", 15000); QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000); } void outlineCurrentPositionIsSeparateFromKeyboardSelection() { const auto capture = [&](const QString &name) { const auto directory = qEnvironmentVariable("DOCVIEW_NAVIGATION_SCREENSHOTS"); if (directory.isEmpty()) return true; if (!QDir().mkpath(directory)) return false; QTest::qWait(60); return window_->grabWindow().save(QDir(directory).filePath(name + ".png")); }; const auto path = documents_.filePath("current-outline.html"); QVERIFY(writeBytes(path, R"(

One

First section

Two

Second section

Three

Third section

)")); const auto original = fileHash(path); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->outline().size(), 3, 10000); QCOMPARE(reader_->outlineTitle(), QString::fromUtf8("目次")); reader_->activateItem(reader_->outline()[0].toMap()); QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 0, 10000); reader_->execute("panel.toc.toggle"); reader_->setContext("toc"); auto *list = window_->findChild("tocList"); QVERIFY(list); list->setProperty("currentIndex", 0); QVariant before; QVERIFY(evaluateWeb("scrollY", &before)); key(Qt::Key_J); QCOMPARE(list->property("currentIndex").toInt(), 1); QVERIFY(QMetaObject::invokeMethod(reader_.get(), "navigationChanged")); QCOMPARE(list->property("currentIndex").toInt(), 1); QTRY_COMPARE(reader_->currentOutline(), 0); QCOMPARE(reader_->currentOutline(), 0); QVariant after; QVERIFY(evaluateWeb("scrollY", &after)); QCOMPARE(after, before); QVERIFY(capture("current-and-selected")); key(Qt::Key_Return); QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000); reader_->setContext("toc"); list->setProperty("currentIndex", 0); key(Qt::Key_H); QTRY_COMPARE(window_->property("visibleCurrentOutline").toInt(), 0); QCOMPARE(reader_->currentOutline(), 1); // Collapsed parent represents its current child. QTRY_VERIFY(list->property("currentItem").value()); QTRY_VERIFY(list->property("currentItem").value()->property("readingCurrent").toBool()); QVERIFY(list->property("currentItem").value()->property("keyboardSelected").toBool()); QVERIFY(capture("collapsed-current")); const auto staleToken = reader_->webToken(); const auto staleTargets = reader_->webNavigationTargets(staleToken, activeWeb()->property("url").toUrl()); const auto noToc = documents_.filePath("headings-only.html"); QVERIFY(writeBytes(noToc, "

First

Body

Second

End

")); reader_->openPath(noToc); QTRY_VERIFY_WITH_TIMEOUT(reader_->webToken() != staleToken && reader_->outline().size() == 2, 10000); QCOMPARE(reader_->outlineTitle(), QString::fromUtf8("見出し一覧")); reader_->activateItem(reader_->outline()[0].toMap()); QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 0, 10000); reader_->activateItem(reader_->outline()[1].toMap()); QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000); key(Qt::Key_Left, Qt::AltModifier); QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 0, 10000); key(Qt::Key_Right, Qt::AltModifier); QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000); reader_->webLocation(staleToken, {{"url", "doc://" + staleToken + "/current-outline.html"}, {"progression", 0.}, {"currentOutline", 0}, {"navigationRevision", staleTargets.value("revision")}}); QCOMPARE(reader_->currentOutline(), 1); QVERIFY(!activeSession()->lastPresented.contains("currentOutline")); QVERIFY(!activeSession()->lastPresented.contains("navigationRevision")); QCOMPARE(fileHash(path), original); } void pdfStatusShowsLabelAndPhysicalPage() { reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000); canvas_->setZoom(400); canvas_->goTo(0); QTRY_VERIFY(reader_->position().contains(QString::fromUtf8("i(実ページ 1)"))); QVERIFY(reader_->position().contains(" / 2")); const auto outline = reader_->outline(); QVERIFY(!outline.isEmpty()); reader_->activateItem(outline.first().toMap()); QTRY_VERIFY(reader_->currentOutline() >= 0); } void outlineCurrentPageDoesNotRequireEarlierPageGeometry() { const auto path = documents_.filePath("lazy-outline.pdf"); QVERIFY(writeBytes(path, pagedPdf(500, 2, 300))); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000); QTRY_COMPARE(reader_->outline().size(), 2); canvas_->goTo(450); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 450, 10000); QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000); } void measureInteractionWorkload() { const auto output = qEnvironmentVariable("DOCVIEW_INTERACTION_OUTPUT"); if (output.isEmpty()) QSKIP("Opt-in T20 workload: run tests/measure_interaction.py; this is measured separately from normal app CTest."); QDir directory(output); QVERIFY(QDir().mkpath(directory.filePath("corpus"))); QJsonArray corpus; const QStringList sources{stressPages_, manyPages_, html_, documents_.filePath("style.css"), documents_.filePath("other.html"), epub_}; QHash originalHashes; for (const auto &source : sources) { const auto name = QFileInfo(source).fileName(); const auto hash = fileHash(source); QVERIFY(!hash.isEmpty()); originalHashes[source] = hash; QVERIFY(QFile::copy(source, directory.filePath("corpus/" + name))); corpus.append(QJsonObject{{"path", name}, {"sha256", QString::fromLatin1(hash.toHex())}, {"size", QFileInfo(source).size()}}); } InteractionMeasurement measurement(reader_.get(), canvas_, window_); bool success = false; const auto save = qScopeGuard([&] { auto result = measurement.result(success); result["corpus"] = corpus; result["corpusDescription"] = "Deterministic generated 5000-page and 50-page vector PDFs; 5000th page green; simple HTML/CSS and two-linear-chapter EPUB. No large scan/complex-vector/font corpus claim."; result["qtVersion"] = qVersion(); result["os"] = QSysInfo::prettyProductName(); result["kernel"] = QSysInfo::kernelVersion(); result["architecture"] = QSysInfo::currentCpuArchitecture(); result["display"] = QJsonObject{{"width", window_->screen()->size().width()}, {"height", window_->screen()->size().height()}, {"reportedRefreshHz", window_->screen()->refreshRate()}, {"devicePixelRatio", window_->devicePixelRatio()}, {"qtPlatform", QGuiApplication::platformName()}, {"quickBackend", qEnvironmentVariable("QT_QUICK_BACKEND")}, {"chromiumFlags", qEnvironmentVariable("QTWEBENGINE_CHROMIUM_FLAGS")}}; result["cacheCondition"] = "Application starts fresh; OS file cache is not cleared. Later phases reuse the application cache."; result["standardCtest"] = "Intentionally QSKIP without DOCVIEW_INTERACTION_OUTPUT; dedicated runner executes this slot and preserves its result."; result["sourceUnchanged"] = std::all_of(sources.begin(), sources.end(), [&](const auto &path) { return fileHash(path) == originalHashes[path]; }); result["finalState"] = QJsonObject{{"state", reader_->state()}, {"format", reader_->format()}, {"notice", reader_->notice()}, {"context", reader_->context()}, {"mode", reader_->mode()}, {"windowFocused", QGuiApplication::focusWindow() == window_}, {"currentPage", canvas_->currentPage()}, {"viewportReady", canvas_->viewportReady()}}; QSaveFile file(directory.filePath("measurement.json")); if (!file.open(QIODevice::WriteOnly) || file.write(QJsonDocument(result).toJson(QJsonDocument::Indented)) < 0 || !file.commit()) qWarning() << "Failed to write interaction measurement"; }); auto waitUntil = [](const std::function &predicate, int timeout = 30000) { QElapsedTimer timer; timer.start(); while (!predicate() && timer.elapsed() < timeout) QTest::qWait(2); return predicate(); }; auto presented = [&](const std::function &ready) { if (!waitUntil(ready)) return false; // Two requested presentations after validated readiness avoid // accepting an old queued frame notification as the target frame. for (int frame = 0; frame < 2; ++frame) { const auto before = measurement.frameSerial; const auto requiredNs = InteractionMeasurement::nowNs(); window_->update(); if (!waitUntil([&] { return measurement.frameSerial > before && measurement.lastFrameNs >= requiredNs && ready(); })) return false; } measurement.sample(); return true; }; auto pdfReady = [&](int count, int page) { return reader_->state() == "Ready" && reader_->format() == "pdf" && reader_->pages().size() == count && canvas_->currentPage() == page && canvas_->viewportReady(); }; bool inputFocusMaintained = true; auto press = [&](Qt::Key code, const QString &text, Qt::KeyboardModifiers modifiers = {}, bool repeat = false, bool release = true) { inputFocusMaintained = inputFocusMaintained && QGuiApplication::focusWindow() == window_; QKeyEvent down(QEvent::KeyPress, code, modifiers, text, repeat); QCoreApplication::sendEvent(window_, &down); if (release) { QKeyEvent up(QEvent::KeyRelease, code, modifiers, text); QCoreApplication::sendEvent(window_, &up); } measurement.sample(); }; auto jump = [&](int physicalPage) { measurement.input({{"kind", "numeric-page-keys"}, {"text", QString::number(physicalPage) + "G"}, {"physicalPage", physicalPage}}); for (const auto digit : QString::number(physicalPage)) press(Qt::Key(Qt::Key_0 + digit.digitValue()), QString(digit)); press(Qt::Key_G, "G", Qt::ShiftModifier); }; window_->resize(1100, 760); measurement.begin("initial-open"); measurement.input({{"kind", "open-path"}, {"source", QFileInfo(stressPages_).fileName()}}); reader_->openPath(stressPages_); QVERIFY2(presented([&] { return pdfReady(5000, 0); }), qPrintable(reader_->notice())); measurement.finish({{"targetPage", 0}, {"viewportReady", true}}); measurement.begin("held-j-key"); int noOps = 0; for (int index = 0; index < 120; ++index) { const auto before = canvas_->offset(); measurement.input({{"kind", "key-press"}, {"key", "j"}, {"autoRepeat", index > 0}, {"index", index}, {"scheduledIntervalMs", 16}}); press(Qt::Key_J, "j", {}, index > 0, false); const bool moved = std::abs(canvas_->offset() - before) > .01; if (!moved) ++noOps; measurement.inputOutcome({{"moved", moved}, {"offsetAfter", canvas_->offset()}, {"pageAfter", canvas_->currentPage()}, {"context", reader_->context()}, {"mode", reader_->mode()}, {"windowFocused", QGuiApplication::focusWindow() == window_}}); QTest::qWait(16); } QKeyEvent release(QEvent::KeyRelease, Qt::Key_J, {}, "j"); QCoreApplication::sendEvent(window_, &release); const int heldPage = canvas_->currentPage(); const double heldOffset = canvas_->offset(); QVERIFY(presented([&] { return pdfReady(5000, heldPage) && std::abs(canvas_->offset() - heldOffset) < 1.; })); measurement.finish({{"keyPressCount", 120}, {"boundaryOrRejectedNoOpCount", noOps}, {"movedCount", 120 - noOps}, {"targetPage", heldPage}}); measurement.begin("rapid-far-jumps"); const QList jumps{5000, 42, 3750, 150, 2500, 1, 4900, 1250, 4999, 20, 3200, 750, 4700, 81, 2100, 300, 4100, 1000, 4800, 10, 3500, 500, 2500, 5000}; for (const auto page : jumps) { jump(page); QTest::qWait(8); } QVERIFY(presented([&] { return pdfReady(5000, 4999); })); QVERIFY(greenPagePixels(window_->grabWindow()) > 500); measurement.finish({{"jumpCount", jumps.size()}, {"targetPage", 4999}, {"greenEndpointVerified", true}}); measurement.begin("resize-burst"); const QList sizes{{480,360},{1100,760},{760,540},{900,600},{480,760}, {1100,360},{640,480},{1000,700},{480,360},{1100,760}}; for (const auto size : sizes) { measurement.input({{"kind", "window-resize"}, {"width", size.width()}, {"height", size.height()}, {"scheduledIntervalMs", 40}}); window_->resize(size); QTest::qWait(40); } QVERIFY(presented([&] { return pdfReady(5000, 4999); })); QVERIFY(greenPagePixels(window_->grabWindow()) > 500); measurement.finish({{"resizeCount", sizes.size()}, {"targetPage", 4999}, {"greenEndpointVerified", true}}); measurement.begin("cancel-staged-document"); const auto retainedToken = reader_->webToken(); measurement.input({{"kind", "open-path"}, {"source", QFileInfo(epub_).fileName()}}); reader_->openPath(epub_); QCOMPARE(reader_->state(), "Opening"); measurement.input({{"kind", "key-press"}, {"key", "Escape"}}); press(Qt::Key_Escape, {}); QVERIFY(presented([&] { return reader_->webToken() == retainedToken && pdfReady(5000, 4999); })); measurement.finish({{"cancelledBeforeCommit", true}, {"oldDocumentRetained", true}, {"targetPage", 4999}}); const QList> replacements{{html_, "html"}, {epub_, "epub"}, {manyPages_, "pdf"}, {stressPages_, "pdf"}}; for (int index = 0; index < replacements.size(); ++index) { const auto source = replacements[index].first, format = replacements[index].second; measurement.begin("document-switch-" + QString::number(index + 1)); if (reader_->format() == "pdf") { jump(reader_->pages().size() > 50 ? 1234 : 42); QTest::qWait(20); } const auto oldToken = reader_->webToken(); measurement.input({{"kind", "open-path"}, {"source", QFileInfo(source).fileName()}}); reader_->openPath(source); QVERIFY2(presented([&] { if (reader_->state() != "Ready" || reader_->webToken() == oldToken || reader_->format() != format) return false; if (format == "pdf") return pdfReady(source == stressPages_ ? 5000 : 50, 0); auto *view = activeWeb(); return view && view->property("documentReady").toBool() && !reader_->benchmarkSnapshot().value("location").toMap().value("resourcePath").toString().isEmpty(); }), qPrintable(reader_->notice())); if (format != "pdf") { QVariant body; QVERIFY(evaluateWeb("document.body.innerText", &body)); QVERIFY(body.toString().contains(format == "html" ? "Readable document" : "Chapter One")); } measurement.finish({{"format", format}, {"source", QFileInfo(source).fileName()}, {"newSessionVerified", true}}); } measurement.begin("final-pdf-endpoint"); jump(5000); QVERIFY(presented([&] { return pdfReady(5000, 4999); })); const auto screenshot = window_->grabWindow(); QVERIFY(greenPagePixels(screenshot) > 500); QVERIFY(screenshot.save(directory.filePath("final-target.png"))); measurement.finish({{"targetPage", 4999}, {"greenEndpointVerified", true}}); for (const auto &source : sources) QCOMPARE(fileHash(source), originalHashes[source]); QVERIFY2(inputFocusMaintained, "The host changed window focus during measured input; this run is not a valid interaction measurement"); success = true; } void memoryPressurePdfStagesPreserveLogicalPosition() { auto *monitor = reader_->findChild("docviewMemoryPressureMonitor"); QVERIFY(monitor); monitor->stop(); QVERIFY(!monitor->running()); const MemorySample low{8ull * 1024 * 1024 * 1024, 100ull * 1024 * 1024, true}; const MemorySample healthy{8ull * 1024 * 1024 * 1024, 4ull * 1024 * 1024 * 1024, true}; for (int i = 0; i < 5; ++i) monitor->observeSample(healthy); QCOMPARE(monitor->level(), MemoryPressureLevel::Normal); const auto original = fileHash(manyPages_); reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); canvas_->setZoom(150); canvas_->goTo(20, .25); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 20, 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); const auto initial = canvas_->location(); auto samePosition = [&](const QVariantMap &before) { const auto after = canvas_->location(); return after.value("pageIndex") == before.value("pageIndex") && std::abs(after.value("xPt").toDouble() - before.value("xPt").toDouble()) < .01 && std::abs(after.value("yPt").toDouble() - before.value("yPt").toDouble()) < .01 && after.value("zoom") == before.value("zoom") && after.value("fitMode") == before.value("fitMode"); }; monitor->observeSample(low); QCOMPARE(monitor->level(), MemoryPressureLevel::NoPrefetch); QCOMPARE(canvas_->memoryPressureLevel(), 1); QCOMPARE(canvas_->rasterResolutionScale(), 1.); QVERIFY(samePosition(initial)); QSignalSpy requests(canvas_, &PdfCanvas::tileRequested); canvas_->goTo(40, .25); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 40, 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QVERIFY(!requests.isEmpty()); for (const auto &request : requests) QVERIFY2(!request[1].toBool(), "Memory pressure dispatched a new speculative render"); const auto location = canvas_->location(); const auto populatedCache = canvas_->cacheCostBytes(); const auto configuredBudget = canvas_->cacheBudgetBytes(); QVERIFY(populatedCache > 0); monitor->observeSample(low); QCOMPARE(monitor->level(), MemoryPressureLevel::Trim); QCOMPARE(canvas_->memoryPressureLevel(), 2); QVERIFY2(canvas_->cacheCostBytes() < populatedCache, "Trim did not release the previously visited, now invisible PDF pages"); QCOMPARE(canvas_->cacheBudgetBytes(), configuredBudget); QVERIFY(samePosition(location)); monitor->observeSample(low); QCOMPARE(monitor->level(), MemoryPressureLevel::Reduced); QCOMPARE(canvas_->memoryPressureLevel(), 3); QCOMPARE(canvas_->rasterResolutionScale(), .5); QVERIFY(reader_->notice().contains(QString::fromUtf8("描画解像度"))); QVERIFY(activeSession()); QVERIFY(activeSession()->meta.value("warnings").toStringList().join('\n').contains(QString::fromUtf8("描画解像度"))); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QVERIFY(samePosition(location)); QVERIFY(canvas_->cacheCostBytes() <= configuredBudget); for (int i = 0; i < 4; ++i) monitor->observeSample(healthy); QCOMPARE(monitor->level(), MemoryPressureLevel::Reduced); monitor->observeSample(healthy); QCOMPARE(monitor->level(), MemoryPressureLevel::Normal); QCOMPARE(canvas_->memoryPressureLevel(), 0); QCOMPARE(canvas_->rasterResolutionScale(), 1.); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QVERIFY(samePosition(location)); QCOMPARE(reader_->state(), "Ready"); QCOMPARE(fileHash(manyPages_), original); } void memoryPressureStopCancelsPendingDocumentAndRequiresReopen() { auto *monitor = reader_->findChild("docviewMemoryPressureMonitor"); QVERIFY(monitor); monitor->stop(); const MemorySample low{8ull * 1024 * 1024 * 1024, 100ull * 1024 * 1024, true}; const MemorySample healthy{8ull * 1024 * 1024 * 1024, 4ull * 1024 * 1024 * 1024, true}; for (int i = 0; i < 5; ++i) monitor->observeSample(healthy); const auto pdfHash = fileHash(manyPages_), epubHash = fileHash(epub_); reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); canvas_->goTo(41); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 41, 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QTRY_VERIFY(activeSession() && activeSession()->lastPresented.value("pageIndex").toInt() == 41); const auto previousToken = reader_->webToken(); QPointer active = activeSession(); QVERIFY(active); QPointer worker = active->worker; QVERIFY(worker); const auto presentedLocation = active->lastPresented; reader_->openPath(epub_); QCOMPARE(reader_->state(), "Opening"); QPointer pending; for (auto *object : reader_->children()) if (auto *session = dynamic_cast(object); session && session != active) pending = session; QVERIFY(pending); for (int i = 0; i < 4; ++i) monitor->observeSample(low); QCOMPARE(monitor->level(), MemoryPressureLevel::Stop); QCOMPARE(canvas_->memoryPressureLevel(), 4); QCOMPARE(reader_->state(), "Recovering"); QVERIFY(reader_->notice().startsWith("E_RESOURCE_LIMIT:")); QVERIFY(active && active->resourcesStopped); QVERIFY(!worker->isConnected()); QVERIFY(worker->idle()); QCOMPARE(canvas_->cacheCostBytes(), 0); QCOMPARE(canvas_->pendingRenderCount(), 0); QVERIFY(!canvas_->viewportReady()); QCOMPARE(active->lastPresented, presentedLocation); QSignalSpy newTiles(canvas_, &PdfCanvas::tileRequested), newPageRequests(canvas_, &PdfCanvas::pageNeeded); const auto stoppedRenders = canvas_->visibleRenderRequestCount(); reader_->activateItem({{"page", 41}, {"precision", "page"}}); reader_->search("request after memory stop"); QTest::qWait(30); QCOMPARE(reader_->state(), "Recovering"); QCOMPARE(worker->queuedRequestCount(), 0); QCOMPARE(worker->activeRequestCount(), 0); QCOMPARE(canvas_->visibleRenderRequestCount(), stoppedRenders); QVERIFY(newTiles.isEmpty()); QVERIFY(newPageRequests.isEmpty()); QTRY_VERIFY(!pending); reader_->openPath(epub_); QCOMPARE(reader_->state(), "Recovering"); QCOMPARE(reader_->webToken(), previousToken); QVERIFY(reader_->notice().startsWith("E_RESOURCE_LIMIT:")); QCOMPARE(reader_->findChildren().size(), 1); for (int i = 0; i < 4; ++i) monitor->observeSample(healthy); QCOMPARE(monitor->level(), MemoryPressureLevel::Stop); monitor->observeSample(healthy); QCOMPARE(monitor->level(), MemoryPressureLevel::Normal); QTest::qWait(100); QCOMPARE(reader_->state(), "Recovering"); QCOMPARE(reader_->webToken(), previousToken); QVERIFY(!worker->isConnected()); QVERIFY(!canvas_->viewportReady()); reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); QVERIFY(reader_->webToken() != previousToken); QTRY_VERIFY(!worker); QCOMPARE(canvas_->memoryPressureLevel(), 0); QCOMPARE(fileHash(manyPages_), pdfHash); QCOMPARE(fileHash(epub_), epubHash); } void memoryPressureStopDisposesWebViewAndRecoversExplicitly() { auto *monitor = reader_->findChild("docviewMemoryPressureMonitor"); QVERIFY(monitor); monitor->stop(); const MemorySample low{8ull * 1024 * 1024 * 1024, 100ull * 1024 * 1024, true}; const MemorySample healthy{8ull * 1024 * 1024 * 1024, 4ull * 1024 * 1024 * 1024, true}; for (int i = 0; i < 5; ++i) monitor->observeSample(healthy); const auto epubHash = fileHash(epub_), htmlHash = fileHash(html_); reader_->openPath(epub_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 15000); QPointer oldView = activeWeb(); const auto oldToken = reader_->webToken(); QPointer session = activeSession(); QVERIFY(session); QPointer oldProfile = session->profile; QVERIFY(oldProfile); QPointer worker = session->worker; QVERIFY(worker); QSignalSpy disposed(reader_.get(), &Controller::disposeWeb); for (int i = 0; i < 4; ++i) monitor->observeSample(low); QCOMPARE(monitor->level(), MemoryPressureLevel::Stop); QCOMPARE(reader_->state(), "Recovering"); QVERIFY(session->resourcesStopped); QVERIFY(!worker->isConnected()); QVERIFY(worker->idle()); QVERIFY(!disposed.isEmpty()); QCOMPARE(disposed.last()[0].toString(), oldToken); QTRY_VERIFY(!oldView); QVERIFY(!activeWeb()); QCOMPARE(reader_->beginRendererProbe(oldToken, 0), 0u); const auto stoppedOutline = reader_->outline(); const auto stoppedMeta = session->meta; const QVariantMap lateHeading{{"title", "Late stale heading"}, {"index", 0}, {"href", "OPS/two.xhtml#two"}, {"source", "html-heading"}, {"precision", "exact"}, {"level", 1}, {"depth", 0}}; reader_->webIndex(oldToken, {{"title", "Late stale index"}, {"headings", QVariantList{lateHeading}}, {"outline", QVariantList{lateHeading}}}); QCOMPARE(reader_->outline(), stoppedOutline); QCOMPARE(session->meta, stoppedMeta); for (int i = 0; i < 5; ++i) monitor->observeSample(healthy); QTest::qWait(100); QCOMPARE(reader_->state(), "Recovering"); QVERIFY(!activeWeb()); reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 15000); QVERIFY(reader_->webToken() != oldToken); QTRY_VERIFY(!session); QTRY_VERIFY(!oldProfile); QTRY_VERIFY(!worker); QVariant text; QVERIFY(evaluateWeb("document.body.innerText", &text)); QVERIFY(text.toString().contains("Readable document")); const auto htmlToken = reader_->webToken(); QPointer htmlView = activeWeb(); QSignalSpy staged(reader_.get(), &Controller::stageWeb); for (int i = 0; i < 4; ++i) monitor->observeSample(low); QCOMPARE(monitor->level(), MemoryPressureLevel::Stop); QCOMPARE(reader_->state(), "Recovering"); QTRY_VERIFY(!htmlView); // A folder dialog may return after the memory-pressure stop. reader_->setHtmlRoot(QUrl::fromLocalFile(QFileInfo(html_).absolutePath())); QCOMPARE(reader_->state(), "Recovering"); QCOMPARE(reader_->webToken(), htmlToken); QVERIFY(reader_->notice().startsWith("E_RESOURCE_LIMIT:")); QVERIFY(staged.isEmpty()); QVERIFY(!activeWeb()); QCOMPARE(fileHash(epub_), epubHash); QCOMPARE(fileHash(html_), htmlHash); } void xhtmlEpubHeadingNavigationStaysWithinChapter() { const auto path = QStringLiteral(DOCVIEW_SOURCE_DIR "/tests/fixtures/web/reflow-rtl.epub"); const auto original = fileHash(path); QVERIFY(!original.isEmpty()); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); QCOMPARE(reader_->format(), "epub"); const auto token = reader_->webToken(); QCOMPARE(activeWeb()->property("url").toUrl().path(), "/OPS/first.xhtml"); QVERIFY(!reader_->outline().isEmpty()); QVERIFY(reader_->outline().first().toMap().value("href").toString().startsWith("OPS/second.xhtml")); QVariant result; QVERIFY(evaluateWeb("({mime:document.contentType,tag:document.querySelector('h1').tagName,headings:__docviewReader.index().headings})", &result)); const auto index = result.toMap(); QCOMPARE(index.value("mime").toString(), "application/xhtml+xml"); QCOMPARE(index.value("tag").toString(), "h1"); const auto headings = index.value("headings").toList(); QCOMPARE(headings.size(), 2); QCOMPARE(headings[0].toMap().value("level").toInt(), 1); QCOMPARE(headings[1].toMap().value("level").toInt(), 2); QVERIFY(headings[1].toMap().value("href").toString().isEmpty()); // The h2 has no author ID. QVariant position; auto atHeading = [this, &position](const QString &tag) { if (!activeWeb() || !evaluateWeb("({top:document.querySelector('" + tag + "').getBoundingClientRect().top,scroll:scrollY,height:innerHeight,total:document.documentElement.scrollHeight,path:location.pathname})", &position)) return false; const auto values = position.toMap(); const double top = values.value("top").toDouble(), height = values.value("height").toDouble(); // This short chapter may clamp its final heading above the bottom // scroll limit, so require the closest attainable alignment. const double expectedTop = qMax(0.0, values.value("scroll").toDouble() + top - qMax(0.0, values.value("total").toDouble() - height)); return values.value("path").toString() == "/OPS/first.xhtml" && top < height && qAbs(top - expectedTop) < 3; }; reader_->activateItem({{"index", 0}}); QTRY_VERIFY_WITH_TIMEOUT(atHeading("h1"), 5000); for (int i = 0; i < 2; ++i) { reader_->execute("nav.heading_next"); QTRY_VERIFY2_WITH_TIMEOUT(atHeading("h2"), QJsonDocument::fromVariant(position).toJson(QJsonDocument::Compact).constData(), 5000); QCOMPARE(reader_->webToken(), token); reader_->execute("nav.heading_previous"); QTRY_VERIFY_WITH_TIMEOUT(atHeading("h1"), 5000); QCOMPARE(reader_->webToken(), token); } QCOMPARE(fileHash(path), original); } void clampedEpubHeadingsReachChapterBoundary() { const auto path = QStringLiteral(DOCVIEW_SOURCE_DIR "/tests/fixtures/performance/standard-500-clamped.epub"); const auto original = fileHash(path); QCOMPARE(original.toHex(), QByteArray("d4136e2c7e5e67bc10e640589c34fc629e80e66edd2ab2bd25dda238c7a2314d")); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); const auto token = reader_->webToken(); QVariant position; auto sample = [this, &position] { return activeWeb() && activeWeb()->property("documentReady").toBool() && evaluateWeb("({path:location.pathname,x:scrollX,y:scrollY,progression:__docviewReader.location().progression,first:document.querySelector('h1').getBoundingClientRect().top})", &position); }; reader_->activateItem({{"index", 0}}); QTRY_VERIFY_WITH_TIMEOUT(sample() && qAbs(position.toMap().value("first").toDouble()) < 3, 5000); QCOMPARE(position.toMap().value("path").toString(), "/chapter-0.xhtml"); int moves = 0; while (position.toMap().value("path").toString() == "/chapter-0.xhtml" && moves < 13) { const auto before = position.toMap(); reader_->execute("nav.heading_next"); QTRY_VERIFY2_WITH_TIMEOUT(sample() && (position.toMap().value("path") != before.value("path") || position.toMap().value("x") != before.value("x") || position.toMap().value("y") != before.value("y")), QJsonDocument::fromVariant(position).toJson(QJsonDocument::Compact).constData(), 5000); ++moves; } QCOMPARE(position.toMap().value("path").toString(), "/chapter-1.xhtml"); QCOMPARE(reader_->webToken(), token); QTRY_VERIFY_WITH_TIMEOUT(sample() && qAbs(position.toMap().value("first").toDouble()) < 3, 5000); reader_->execute("nav.heading_previous"); QTRY_VERIFY_WITH_TIMEOUT(sample() && position.toMap().value("path").toString() == "/chapter-0.xhtml" && position.toMap().value("progression").toDouble() > .999, 5000); reader_->execute("nav.heading_previous"); QTRY_VERIFY_WITH_TIMEOUT(sample() && position.toMap().value("path").toString() == "/chapter-0.xhtml" && position.toMap().value("progression").toDouble() < .999, 5000); QCOMPARE(reader_->webToken(), token); QCOMPARE(fileHash(path), original); } void replacingOpenDocumentDoesNotTransferSavedPosition_data() { QTest::addColumn("kind"); for (const auto &kind : {"pdf", "html", "zip", "epub"}) QTest::newRow(kind) << QString(kind); } void replacingOpenDocumentDoesNotTransferSavedPosition() { QFETCH(QString, kind); reader_->clearHistory(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=20\n")); QVERIFY(reader_->command("reload")); const QString original = kind == "pdf" ? manyPages_ : kind == "html" ? html_ : kind == "zip" ? zip_ : epub_; const QString path = documents_.filePath("changed-during-reading." + kind); QVERIFY(QFile::copy(original, path)); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); auto *session = activeSession(); QVERIFY(session); auto *store = reader_->findChild(); QVERIFY(store); if (kind == "pdf") { reader_->execute("nav.page", {{"page", 42}}); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); } QTRY_VERIFY_WITH_TIMEOUT(!session->lastPresented.isEmpty(), 10000); QVERIFY(store->flush()); const auto prior = store->restore(path); QVERIFY(!prior.isEmpty()); const auto identity = StateStore::fileIdentity(path); QVERIFY(QFile::rename(path, path + ".original")); QVERIFY(QFile::copy(original, path)); QFile replacement(path); QVERIFY(replacement.open(QIODevice::ReadWrite)); QVERIFY(replacement.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime)); replacement.close(); QVERIFY(StateStore::fileIdentity(path).value("fileIdentity") != identity.value("fileIdentity")); // Closing is an unconditional save point while the renderer still owns A. reader_->closeDocument(); QVERIFY(store->flush()); QVERIFY2(store->restore(path).isEmpty(), "The old document's location was rebound to the replacement's identity"); const auto disk = QJsonDocument::fromJson([&] { QFile file(store->filePath()); if (!file.open(QIODevice::ReadOnly)) return QByteArray{}; return file.readAll(); }()).object(); QCOMPARE(disk.value("documents").toArray().first().toObject().value("identity").toObject().toVariantMap(), identity); QStringList notices; const auto connection = connect(reader_.get(), &Controller::noticeChanged, reader_.get(), [&] { notices << reader_->notice(); }); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(notices.join('\n').contains("E_LOCATOR_UNRESOLVED"), 5000); if (kind == "pdf") QCOMPARE(canvas_->currentPage(), 0); disconnect(connection); QCOMPARE(fileHash(path), fileHash(original)); QCOMPARE(fileHash(path + ".original"), fileHash(original)); } void htmlOutlineLoadingIsNotAbsence() { bool observed = false, loading = false, toggled = false, placeholderVisible = false; QString notice; const auto connection = connect(reader_.get(), &Controller::stateChanged, reader_.get(), [&] { if (reader_->state() != "Ready" || reader_->format() != "html" || observed) return; observed = true; loading = activeSession()->meta.value("capabilities").toMap().value("outline").toString() == "loading"; reader_->execute("panel.toc.toggle"); toggled = window_->property("tocVisible").toBool(); notice = reader_->notice(); const auto *placeholder = window_->findChild("outlineLoading"); placeholderVisible = placeholder && placeholder->isVisible() && placeholder->property("text").toString().contains(QStringLiteral("読み込")); }); reader_->openPath(html_); QTRY_VERIFY_WITH_TIMEOUT(observed, 15000); QVERIFY(loading); QVERIFY2(toggled, "An outline request during DOM indexing must retain the panel request"); QVERIFY(placeholderVisible); QVERIFY(!notice.contains(QStringLiteral("目次がありません"))); QTRY_VERIFY_WITH_TIMEOUT(!reader_->outline().isEmpty(), 10000); QVERIFY(window_->property("tocVisible").toBool()); QVERIFY(!window_->findChild("outlineLoading")->isVisible()); disconnect(connection); } void pdfHeadingFallbackUsesOnlyInternalOutline_data() { QTest::addColumn("mixed"); QTest::newRow("external-and-blocked-only") << false; QTest::newRow("mixed-internal-external-blocked") << true; } void pdfHeadingFallbackUsesOnlyInternalOutline() { QFETCH(bool, mixed); const auto path = documents_.filePath(mixed ? "mixed-outline-actions.pdf" : "external-outline-actions.pdf"); QVERIFY(writeBytes(path, pagedPdf(4, mixed ? 4 : 2, 1, 2))); const auto original = fileHash(path); QSignalSpy external(reader_.get(), &Controller::externalLinkRequested); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); auto *session = activeSession(); QVERIFY(session); QTRY_VERIFY_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString() != "loading", 15000); QCOMPARE(session->meta.value("capabilities").toMap().value("headings").toString(), mixed ? "supported" : "unavailable"); QCOMPARE(reader_->outline().size(), mixed ? 4 : 2); // Preserve all original TOC rows. QCOMPARE(reader_->benchmarkSnapshot().value("headingCount").toInt(), mixed ? 2 : 0); reader_->execute("nav.heading_next"); if (mixed) { QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 2, 10000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); reader_->execute("nav.heading_next"); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 3, 10000); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); reader_->execute("nav.heading_previous"); QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 2, 10000); } else { QCOMPARE(canvas_->currentPage(), 0); QVERIFY(reader_->notice().contains(QStringLiteral("対象の情報がありません"))); } QVERIFY(external.isEmpty()); QCOMPARE(fileHash(path), original); } void epubFailedHeadingBoundaryDoesNotLeakIntoChapterNavigation() { auto files = epubFixture(); for (auto &file : files) if (file.first == "OPS/one.xhtml" || file.first == "OPS/two.xhtml") file.second = "Boundary" "

First heading

" "

Last heading

"; const auto path = documents_.filePath("failed-heading-boundary.epub"); QVERIFY(makeZip(path, files)); const auto original = fileHash(path); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); QVariant location; auto position = [&] { return evaluateWeb("({path:location.pathname,y:scrollY})", &location); }; QTRY_VERIFY(position() && location.toMap().value("path").toString() == "/OPS/one.xhtml"); reader_->execute("nav.heading_previous"); QTRY_VERIFY_WITH_TIMEOUT(reader_->notice().contains(QStringLiteral("章の端")), 5000); reader_->execute("nav.chapter_next"); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool() && position() && location.toMap().value("path").toString() == "/OPS/two.xhtml", 10000); QTest::qWait(200); QVERIFY(position()); QVERIFY2(location.toMap().value("y").toDouble() < 1, "A failed previous-heading request must not select the next chapter's last heading"); // A successful heading boundary still deliberately resolves the previous chapter's last heading. reader_->execute("nav.heading_previous"); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool() && position() && location.toMap().value("path").toString() == "/OPS/one.xhtml" && location.toMap().value("y").toDouble() > 1000, 10000); QCOMPARE(fileHash(path), original); } void htmlEscapeClearsSelectedLink() { reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); QVariant result; key(Qt::Key_Tab); QTRY_VERIFY_WITH_TIMEOUT(evaluateWeb("document.activeElement.id", &result) && result.toString() == "local", 5000); key(Qt::Key_Escape); QTRY_VERIFY_WITH_TIMEOUT(evaluateWeb("({active:document.activeElement.id,outline:document.getElementById('local').style.outline})", &result) && result.toMap().value("active").toString() != "local" && result.toMap().value("outline").toString().isEmpty(), 5000); QSignalSpy applied(reader_.get(), &Controller::webActionApplied); const auto url = activeWeb()->property("url").toUrl(); key(Qt::Key_Return); QTRY_VERIFY_WITH_TIMEOUT(!applied.isEmpty(), 5000); QCOMPARE(activeWeb()->property("url").toUrl(), url); } void temporaryUiRestoresPanelFocus_data() { QTest::addColumn("panel"); QTest::addColumn("temporary"); for (const auto &panel : {QString("toc"), QString("pages")}) for (const auto &temporary : {QString("command"), QString("help"), QString("info"), QString("file")}) QTest::newRow(qPrintable(panel + "-" + temporary)) << panel << temporary; } void temporaryUiRestoresPanelFocus() { QFETCH(QString, panel); QFETCH(QString, temporary); const auto path = documents_.filePath("focus-outline.pdf"); QVERIFY(writeBytes(path, pagedPdf(8, 4))); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); reader_->execute("panel." + panel + ".toggle"); reader_->execute("focus.next"); QCOMPARE(reader_->context(), panel); if (temporary == "command") key(Qt::Key_Colon); else if (temporary == "file") reader_->execute("file.open"); else reader_->execute(temporary == "help" ? "help.toggle" : "document.info"); QTRY_VERIFY(reader_->mode() != "normal"); QTest::qWait(100); if (temporary == "file") { QObject *dialog = nullptr; for (auto *object : window_->findChildren()) if (object->property("title").toString() == QStringLiteral("文書を開く") && object->metaObject()->indexOfMethod("reject()") >= 0) { dialog = object; break; } QVERIFY(dialog); QVERIFY(QMetaObject::invokeMethod(dialog, "reject")); } else key(Qt::Key_Escape); QTRY_COMPARE(reader_->mode(), "normal"); QCOMPARE(reader_->context(), panel); auto *focus = qobject_cast(QGuiApplication::focusObject()); QVERIFY(focus && focus->hasActiveFocus()); // A bare Xvfb server has no window manager to reactivate the parent // after a separate platform file-dialog window closes. if (temporary == "file") window_->requestActivate(); QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window_, 5000); // An explicit successful command takes precedence over the saved origin. key(Qt::Key_Colon); QVERIFY(QGuiApplication::focusObject()->setProperty("text", "cancel")); key(Qt::Key_Return); QTRY_COMPARE(reader_->mode(), "normal"); QCOMPARE(reader_->context(), "content"); QVERIFY(canvas_->hasActiveFocus()); reader_->execute("focus.next"); QCOMPARE(reader_->context(), panel); // A normal command may deliberately hide its origin. Fall back to content. key(Qt::Key_Colon); QVERIFY(QGuiApplication::focusObject()->setProperty("text", panel == "toc" ? "toctoggle" : "pagestoggle")); key(Qt::Key_Return); QCOMPARE(reader_->mode(), "normal"); QCOMPARE(reader_->context(), "content"); QVERIFY(canvas_->hasActiveFocus()); } void webZoomPersistsAcrossReopen_data() { QTest::addColumn("kind"); for (const auto &kind : {QString("html"), QString("zip"), QString("epub"), QString("fixed")}) QTest::newRow(qPrintable(kind)) << kind; } void webZoomPersistsAcrossReopen() { QFETCH(QString, kind); reader_->clearHistory(); QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=0\n")); QVERIFY(reader_->command("reload")); const auto path = kind == "html" ? html_ : kind == "zip" ? zip_ : kind == "epub" ? epub_ : QString(DOCVIEW_SOURCE_DIR "/tests/fixtures/web/fixed-mixed.epub"); const auto original = fileHash(path); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); QTest::qWait(250); reader_->execute("zoom.in"); reader_->execute("zoom.in"); QTest::qWait(700); auto *store = reader_->findChild(); QVERIFY(store); QVERIFY(store->flush()); const auto saved = store->restore(path); QVERIFY2(std::abs(saved.value("zoom").toDouble() - 132.25) < .1, qPrintable("Persisted zoom: " + saved.value("zoom").toString())); QVERIFY(reader_->position().contains("132%")); cleanup(); startUi(); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); QTRY_VERIFY_WITH_TIMEOUT(reader_->position().contains("132%"), 10000); const auto actualZoom = kind == "fixed" ? activeWeb()->parent()->property("spreadZoom").toDouble() : activeWeb()->property("zoomFactor").toDouble(); QVERIFY(std::abs(actualZoom - 1.3225) < .01); if (kind == "fixed") { QVERIFY(captureRecentScreen("fixed-epub-restored-zoom")); reader_->execute("zoom.fit_width"); QTRY_VERIFY(reader_->position().contains(QStringLiteral("100%(幅合わせ)"))); QTRY_VERIFY(activeSession()->lastPresented.value("fitWidth").toBool()); QVERIFY(reader_->findChild()->flush()); cleanup(); startUi(); reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(reader_->position().contains(QStringLiteral("100%(幅合わせ)")), 10000); QVERIFY(activeWeb()->parent()->property("spreadFitWidth").toBool()); } QCOMPARE(fileHash(path), original); } void webZoomLocationSchemaRejectsInvalidDisplayState() { reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); QVariant raw; QVERIFY(evaluateWeb("globalThis.__docviewReader.command('location',{})", &raw)); const auto token = reader_->webToken(); const QList invalid{{{"zoom", 24.99}}, {{"zoom", 500.01}}, {{"zoom", "125"}}, {{"zoom", true}}, {{"zoom", std::numeric_limits::quiet_NaN()}}, {{"fitWidth", "true"}}, {{"panX", -0.1}}, {{"panY", 1.1}}}; for (const auto &fields : invalid) { auto location = raw.toMap(); for (auto it = fields.begin(); it != fields.end(); ++it) location[it.key()] = it.value(); const auto before = reader_->benchmarkSnapshot().value("location"); reader_->webLocation(token, location); QVERIFY(reader_->notice().startsWith("E_RESOURCE_BLOCKED")); QCOMPARE(reader_->benchmarkSnapshot().value("location"), before); } for (const auto zoom : {25., 500.}) { auto location = raw.toMap(); location["zoom"] = zoom; reader_->webLocation(token, location); QCOMPARE(reader_->benchmarkSnapshot().value("location").toMap().value("zoom").toDouble(), zoom); } } void failedOpenIdentifiesAttemptWithoutChangingActiveWarnings() { reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); const auto token = reader_->webToken(), title = reader_->title(); const auto warnings = activeSession()->meta.value("warnings"); const auto failed = documents_.filePath("broken-target-B.pdf"); QVERIFY(writeBytes(failed, "%PDF-1.7\ninvalid")); reader_->openPath(failed); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); QCOMPARE(reader_->webToken(), token); QCOMPARE(reader_->title(), title); QCOMPARE(activeSession()->meta.value("warnings"), warnings); QVERIFY(reader_->notice().contains("broken-target-B.pdf")); QVERIFY(reader_->notice().contains(QStringLiteral("確認")) || reader_->notice().contains(QStringLiteral("開き直"))); reader_->notify({}); QSignalSpy info(reader_.get(), &Controller::uiCommand); reader_->execute("document.info"); QVERIFY(!info.isEmpty()); const auto text = info.last()[1].toMap().value("text").toString(); QVERIFY(text.contains("broken-target-B.pdf")); QVERIFY(text.contains("E_PDF_CORRUPT")); QVERIFY(text.contains(QStringLiteral("最後に開こうとした文書"))); QVERIFY(captureRecentScreen("open-failure-with-active-document")); QCOMPARE(activeSession()->meta.value("warnings"), warnings); key(Qt::Key_Escape); const auto missing = documents_.filePath("missing-target-C.html"); reader_->openPath(missing); QCOMPARE(reader_->webToken(), token); QVERIFY(reader_->notice().startsWith("E_OPEN_FAILED")); QVERIFY(reader_->notice().contains("missing-target-C.html")); QVERIFY(reader_->notice().contains("Ctrl+O")); reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); info.clear(); reader_->execute("document.info"); QVERIFY(!info.last()[1].toMap().value("text").toString().contains("missing-target-C.html")); } }; // A separate GUI process reads the persisted choice using the production // Controller, workers and Main.qml. No in-memory cache survives this boundary. static int probePersistedZipEntry(const QString &root) { const QDir directory(root); const StoragePaths paths{directory.filePath("config.toml"), directory.filePath("state/state.json"), directory.filePath("cache"), directory.filePath("logs")}; Controller reader; reader.initialize(paths.configFile, false, paths); QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("reader", &reader); engine.load(QUrl("qrc:/qml/Main.qml")); if (engine.rootObjects().isEmpty()) return 21; auto *window = qobject_cast(engine.rootObjects().first()); if (!window) return 22; reader.attachWindow(window); int choices = 0; QObject::connect(&reader, &Controller::entryNeeded, &reader, [&] { ++choices; }); reader.openPath(directory.filePath("remember-entry.zip")); QElapsedTimer elapsed; elapsed.start(); while (elapsed.elapsed() < 15000) { QCoreApplication::processEvents(); if (choices) return 23; if (reader.state() == "Ready") { for (auto *object : window->findChildren()) { if (object->property("documentToken").toString() == reader.webToken() && object->property("isCurrent").toBool() && object->property("documentReady").toBool()) return object->property("url").toUrl().path().endsWith("b/second.html") ? 0 : 24; } } QTest::qWait(5); } qWarning() << "ZIP entry restart probe failed:" << reader.state() << reader.notice(); return 25; } int main(int argc, char **argv) { QTemporaryDir isolatedState; if (!isolatedState.isValid()) return 2; qputenv("XDG_CONFIG_HOME", isolatedState.filePath("config").toUtf8()); qputenv("XDG_STATE_HOME", isolatedState.filePath("state").toUtf8()); qputenv("XDG_CACHE_HOME", isolatedState.filePath("cache").toUtf8()); registerDocumentScheme(); QtWebEngineQuick::initialize(); QGuiApplication application(argc, argv); application.setQuitOnLastWindowClosed(false); application.setApplicationName("DocView integration tests"); application.setOrganizationName("DocView"); qmlRegisterType("DocView", 1, 0, "PdfCanvas"); if (argc == 3 && QByteArray(argv[1]) == "--zip-entry-probe") return probePersistedZipEntry(QString::fromLocal8Bit(argv[2])); AppTest test; return QTest::qExec(&test, argc, argv); } #include "test_app.moc"