3270 lines
179 KiB
C++
3270 lines
179 KiB
C++
#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 <QCryptographicHash>
|
||
#include <QElapsedTimer>
|
||
#include <QFile>
|
||
#include <QGuiApplication>
|
||
#include <QInputMethodEvent>
|
||
#include <QJsonArray>
|
||
#include <QJsonDocument>
|
||
#include <QJsonObject>
|
||
#include <QProcess>
|
||
#include <QQmlApplicationEngine>
|
||
#include <QQmlContext>
|
||
#include <QQmlExpression>
|
||
#include <QSignalSpy>
|
||
#include <QSaveFile>
|
||
#include <QScreen>
|
||
#include <QSysInfo>
|
||
#include <QScopeGuard>
|
||
#include <QTemporaryDir>
|
||
#include <QtTest>
|
||
#include <QtWebEngineQuick>
|
||
#include <limits>
|
||
#include <cmath>
|
||
#include <memory>
|
||
#include <zip.h>
|
||
#ifdef Q_OS_LINUX
|
||
#include <unistd.h>
|
||
#include <sys/socket.h>
|
||
#include <signal.h>
|
||
#include <QScopeGuard>
|
||
#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<QByteArray> 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<qsizetype> 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<QPair<QByteArray, QByteArray>> &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<zip_uint64_t>(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<zip_uint64_t>(index), ZIP_CM_STORE, 0) < 0
|
||
|| zip_file_set_external_attributes(archive, static_cast<zip_uint64_t>(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"(<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Fixture</title>
|
||
<link rel="stylesheet" href="style.css"></head><body>
|
||
<h1 id="first">First heading</h1><p>Readable document</p><div style="height:1200px"></div>
|
||
<h2 id="second">Second heading</h2><a id="local" href="other.html#destination">Local target</a>
|
||
<div style="height:1200px"></div>
|
||
<script>document.body.setAttribute('data-document-script', 'executed');</script>
|
||
</body></html>)";
|
||
}
|
||
|
||
QList<QPair<QByteArray, QByteArray>> epubFixture()
|
||
{
|
||
return {
|
||
{"mimetype", "application/epub+zip"},
|
||
{"META-INF/container.xml", R"(<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0"><rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles></container>)"},
|
||
{"OPS/book.opf", R"(<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">urn:docview:integration</dc:identifier><dc:title>Integration book</dc:title><dc:language>en</dc:language></metadata><manifest><item id="one" href="one.xhtml" media-type="application/xhtml+xml"/><item id="aside" href="aside.xhtml" media-type="application/xhtml+xml"/><item id="two" href="two.xhtml" media-type="application/xhtml+xml"/><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/></manifest><spine><itemref idref="one"/><itemref idref="aside" linear="no"/><itemref idref="two"/></spine></package>)"},
|
||
{"OPS/nav.xhtml", R"(<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body><nav epub:type="toc"><ol><li><a href="two.xhtml#two">Second in contents</a></li><li><a href="one.xhtml#one">First in reading order</a></li></ol></nav></body></html>)"},
|
||
{"OPS/one.xhtml", R"(<html xmlns="http://www.w3.org/1999/xhtml"><head><title>One</title></head><body><h1 id="one">Chapter One</h1><p>First spine chapter.</p></body></html>)"},
|
||
{"OPS/aside.xhtml", R"(<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Aside</title></head><body><h1>Nonlinear aside</h1></body></html>)"},
|
||
{"OPS/two.xhtml", R"(<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Two</title></head><body><h1 id="two">Chapter Two</h1><p>Second linear chapter.</p></body></html>)"}
|
||
};
|
||
}
|
||
|
||
} // namespace
|
||
#endif
|
||
|
||
class AppTest : public QObject {
|
||
Q_OBJECT
|
||
QTemporaryDir documents_;
|
||
std::unique_ptr<Controller> reader_;
|
||
std::unique_ptr<QQmlApplicationEngine> 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<Session *>(object); session && session->token == reader_->webToken())
|
||
return session;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
QObject *activeWeb() const
|
||
{
|
||
if (!window_)
|
||
return nullptr;
|
||
for (auto *object : window_->findChildren<QObject *>()) {
|
||
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<char>(key), modifiers);
|
||
else
|
||
QTest::keyClick(window_, key, modifiers);
|
||
QCoreApplication::processEvents();
|
||
}
|
||
|
||
void startUi(bool readOnly = false)
|
||
{
|
||
reader_ = std::make_unique<Controller>();
|
||
reader_->initialize(configuration_, readOnly, testStorage());
|
||
engine_ = std::make_unique<QQmlApplicationEngine>();
|
||
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<QQuickWindow *>(engine_->rootObjects().first());
|
||
QVERIFY(window_);
|
||
reader_->attachWindow(window_);
|
||
canvas_ = window_->findChild<PdfCanvas *>();
|
||
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<bool(const QJsonObject &, int)> 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"), "<!doctype html><h1 id='destination'>Linked destination</h1>"));
|
||
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", "<!doctype html><h1 id='destination'>Archive linked destination</h1>"}}));
|
||
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<StateStore *>();
|
||
QVERIFY(store);
|
||
QList<QPair<QString, QByteArray>> 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<QQuickItem *>("recentDocumentsList");
|
||
auto *clear = window_->findChild<QQuickItem *>("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<QQuickItem *>("initialScreen");
|
||
QVERIFY(screen && screen->height() < window_->height());
|
||
auto *notice = window_->findChild<QQuickItem *>("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<QObject *>("historyDialog");
|
||
auto *confirmation = window_->findChild<QObject *>("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<StateStore *>();
|
||
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<QQuickItem *>("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<bool>("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<StateStore *>();
|
||
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<int> 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<qint64> 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<QString>("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<QQuickItem *>("navigationSidebar");
|
||
auto *content = window_->findChild<QQuickItem *>("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<QString>("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<Session> 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<Session> 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<bool>("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<ResourceFailure> 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>({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<int>("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<void(QCborMap)> 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<void(QCborMap)> 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<Session *>(object); candidate && candidate->token != previous && !candidate->resourcesStopped)
|
||
pending = candidate;
|
||
QVERIFY(pending && pending->worker);
|
||
pending->worker->setFontRequestHandler([](const QString &, const QCborMap &, std::function<void(QCborMap)> 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<bool>("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<void(QCborMap)> oldReply, newReply;
|
||
active->worker->setFontRequestHandler([&](const QString &, const QCborMap &, std::function<void(QCborMap)> 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<Session *>(object); candidate && candidate != active && !candidate->resourcesStopped) staging = candidate;
|
||
QVERIFY(staging && staging->worker);
|
||
staging->worker->setFontRequestHandler([&](const QString &, const QCborMap &, std::function<void(QCborMap)> 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<Session> 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<StateStore *>();
|
||
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<StateStore *>();
|
||
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<bool>("hasText");
|
||
QTest::addColumn<QString>("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(), "<b>Literal comment</b>");
|
||
QVERIFY(std::isfinite(canvas_->offset()));
|
||
QCOMPARE(canvas_->currentPage(), 0);
|
||
key(Qt::Key_Escape);
|
||
QTRY_COMPARE(reader_->mode(), "normal");
|
||
}
|
||
|
||
void pdfCommentsAreReadableWithKeyboard_data()
|
||
{
|
||
QTest::addColumn<bool>("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<QObject *>("infoDialog");
|
||
auto *body = window_->findChild<QObject *>("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(), "<b>Literal comment</b>");
|
||
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<QString>("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 = "<!DOCTYPE html><html xmlns='http://www.w3.org/1999/xhtml'><head><meta charset='utf-8'/><title>Resource checks</title>"
|
||
"<link rel='stylesheet' href='absent-secret-style.css'/><link rel='stylesheet' href='https://invalid.example/secret-external.css'/>"
|
||
"<style>@font-face{font-family:missing;src:url(absent-secret-font.woff)}p{font-family:missing}</style>"
|
||
"</head><body><h1>Readable despite missing resources</h1><p>Local font check</p><img src='absent-secret-image.png'/>"
|
||
"<img src='https://invalid.example/secret-external.png'/></body></html>";
|
||
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, "<!doctype html><h1>Readable resource classes</h1>"));
|
||
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<QString>("kind"); QTest::addColumn<QString>("reason"); QTest::addColumn<QString>("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", "<!doctype html><h1>Still readable</h1>"}, {"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<QString>("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", "<!doctype html><h1>Preserved content</h1>"}, {"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> session = activeSession(); QVERIFY(session && session->worker);
|
||
auto *socket = session->worker->findChild<QLocalSocket *>(); 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<QList<ResourceFailure>>();
|
||
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>{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<ResourceFailure>{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<QVariantMap> 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<double>::infinity()}},
|
||
{{"csp.image", std::numeric_limits<double>::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<QObject *>("infoDialog");
|
||
auto *scroll = window_->findChild<QObject *>("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<StateStore *>();
|
||
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<StateStore *>();
|
||
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<QVariantMap> 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<double>::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<double>::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<StateStore *>();
|
||
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<bool>("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<StateStore *>();
|
||
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<QString>("writingMode");
|
||
QTest::addColumn<QString>("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("</manifest>", "<item id=\"pre\" href=\"pre.xhtml\" media-type=\"application/xhtml+xml\"/><item id=\"post\" href=\"post.xhtml\" media-type=\"application/xhtml+xml\"/></manifest>");
|
||
files[2].second.replace("<spine>", "<spine page-progression-direction=\"rtl\"><itemref idref=\"pre\" linear=\"no\"/>");
|
||
files[2].second.replace("</spine>", "<itemref idref=\"post\" linear=\"no\"/></spine>");
|
||
const auto style = QString("<body style=\"margin:0;writing-mode:%1;direction:%2;%3\">")
|
||
.arg(writingMode, direction, writingMode == "vertical-rl" ? "min-width:4000px" : "min-height:4000px").toUtf8();
|
||
files[4].second.replace("<body>", style);
|
||
files[6].second.replace("<body>", style);
|
||
files.append(QPair<QByteArray, QByteArray>{"OPS/pre.xhtml", "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body><h1>Nonlinear front</h1></body></html>"});
|
||
files.append(QPair<QByteArray, QByteArray>{"OPS/post.xhtml", "<html xmlns=\"http://www.w3.org/1999/xhtml\"><body><h1>Nonlinear end</h1></body></html>"});
|
||
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<bool>("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", "<!doctype html><h1>First entry</h1>"},
|
||
{"b/second.html", "<!doctype html><h1>Chosen entry</h1>"}}));
|
||
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<QQuickItem *>("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<StateStore *>();
|
||
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<StateStore *>()->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<bool>("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", "<h1>One</h1>"}, {"two.html", "<h1>Two</h1>"}}));
|
||
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<StateStore *>()->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<QString>("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", "<h1>First</h1>"}, {"second.html", "<h1>Second</h1>"}}));
|
||
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<StateStore *>()->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", "<h1>Changed original</h1>"}, {"third.html", "<h1>New candidate</h1>"}}));
|
||
} 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", "<h1>First</h1>"}, {"second.html", "<h1>Second</h1>"}}));
|
||
QSignalSpy choices(reader_.get(), &Controller::entryNeeded);
|
||
reader_->openPath(path);
|
||
QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000);
|
||
auto *dialog = window_->findChild<QObject *>("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<StateStore *>()->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<QString>("policy");
|
||
QTest::addColumn<bool>("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 = "<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\"><metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>Spread proof</dc:title><meta property=\"rendition:layout\">pre-paginated</meta><meta property=\"rendition:spread\">" + policy.toUtf8() + "</meta></metadata><manifest>";
|
||
QByteArray spine;
|
||
for (int i = 1; i <= 7; ++i) {
|
||
const auto number = QByteArray::number(i);
|
||
package += "<item id=\"p" + number + "\" href=\"p" + number + ".xhtml\" media-type=\"application/xhtml+xml\"/>";
|
||
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 += "<itemref idref=\"p" + number + "\" properties=\"" + properties + "\"/>";
|
||
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", "<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><meta name=\"viewport\" content=\"width=400,height=600\"/><style>html,body{margin:0;padding:0}body{background:" + color + ";color:#102040;font:24px sans-serif}article{box-sizing:border-box;width:400px;height:600px;border:6px solid #284a70;padding:20px}h1{font-size:42px}svg{width:348px;height:280px}</style></head><body><article><h1>" + label + "</h1><p>Fixed-layout spread proof</p><svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 348 280\"><rect width=\"348\" height=\"280\" fill=\"#457c96\"/><circle cx=\"174\" cy=\"140\" r=\"95\" fill=\"#f4b54d\"/></svg><a href=\"p3.xhtml\">Center page</a></article></body></html>"});
|
||
}
|
||
package += "</manifest><spine page-progression-direction=\"" + QByteArray(rtl ? "rtl" : "ltr") + "\">" + spine + "</spine></package>";
|
||
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<QQuickItem *>())
|
||
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<QQuickItem *>(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<QQuickItem *>(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<QQuickItem *>(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<QQuickItem *>(activeWeb())->parentItem()->width() < qobject_cast<QQuickItem *>(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<QQuickItem *>(activeWeb())->parentItem()->width() > qobject_cast<QQuickItem *>(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<QQuickItem *>(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"(<html><head><style>section{height:1000px}body{margin:0}</style></head><body>
|
||
<nav role="doc-toc"><ol><li><a href="#one">One</a><ol><li><a href="#two%2Fpart%23%25">Two</a></li></ol></li><li><a href="#three">Three</a></li></ol></nav>
|
||
<section><h1 id="one">One</h1><p>First section</p></section><section><h2 id="two/part#%">Two</h2><p>Second section</p></section>
|
||
<section><h1 id="three">Three</h1><p>Third section</p></section></body></html>)"));
|
||
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<QObject *>("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<QQuickItem *>());
|
||
QTRY_VERIFY(list->property("currentItem").value<QQuickItem *>()->property("readingCurrent").toBool());
|
||
QVERIFY(list->property("currentItem").value<QQuickItem *>()->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, "<html><body><h1>First</h1><p style='height:1200px'>Body</p><h2>Second</h2><p style='height:1200px'>End</p></body></html>"));
|
||
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<QString, QByteArray> 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<bool()> &predicate, int timeout = 30000) {
|
||
QElapsedTimer timer; timer.start();
|
||
while (!predicate() && timer.elapsed() < timeout) QTest::qWait(2);
|
||
return predicate();
|
||
};
|
||
auto presented = [&](const std::function<bool()> &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<int> 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<QSize> 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<QPair<QString, QString>> 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<MemoryPressureMonitor *>("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<MemoryPressureMonitor *>("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<Session> active = activeSession(); QVERIFY(active);
|
||
QPointer<WorkerProcess> worker = active->worker; QVERIFY(worker);
|
||
const auto presentedLocation = active->lastPresented;
|
||
reader_->openPath(epub_);
|
||
QCOMPARE(reader_->state(), "Opening");
|
||
QPointer<Session> pending;
|
||
for (auto *object : reader_->children())
|
||
if (auto *session = dynamic_cast<Session *>(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<WorkerProcess *>().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<MemoryPressureMonitor *>("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<QObject> oldView = activeWeb();
|
||
const auto oldToken = reader_->webToken();
|
||
QPointer<Session> session = activeSession(); QVERIFY(session);
|
||
QPointer<QQuickWebEngineProfile> oldProfile = session->profile; QVERIFY(oldProfile);
|
||
QPointer<WorkerProcess> 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<QObject> 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<QString>("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<StateStore *>(); 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<QQuickItem *>("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<QQuickItem *>("outlineLoading")->isVisible());
|
||
disconnect(connection);
|
||
}
|
||
void pdfHeadingFallbackUsesOnlyInternalOutline_data()
|
||
{
|
||
QTest::addColumn<bool>("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 = "<html xmlns='http://www.w3.org/1999/xhtml'><head><title>Boundary</title></head>"
|
||
"<body style='margin:0'><h1>First heading</h1><div style='height:1400px'></div>"
|
||
"<h2>Last heading</h2><div style='height:1000px'></div></body></html>";
|
||
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<QString>("panel"); QTest::addColumn<QString>("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<QObject *>())
|
||
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<QQuickItem *>(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<QString>("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<StateStore *>(); 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<StateStore *>()->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<QVariantMap> invalid{{{"zoom", 24.99}}, {{"zoom", 500.01}}, {{"zoom", "125"}},
|
||
{{"zoom", true}}, {{"zoom", std::numeric_limits<double>::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<QQuickWindow *>(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<QObject *>()) {
|
||
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<PdfCanvas>("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"
|