Files
docview/tests/test_core.cpp
T
2026-09-21 13:41:40 +09:00

858 lines
40 KiB
C++

#include "core/config.h"
#include "core/input.h"
#include "core/state.h"
#include "core/types.h"
#include "common/contracts.h"
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QtTest>
#include <limits>
using namespace docview;
class CoreTest : public QObject {
Q_OBJECT
private slots:
void defaultConfiguration();
void atomicReload();
void reloadKeepsSelectedSource();
void invalidConfiguration_data();
void invalidConfiguration();
void bindingPrecedenceAndUnbind();
void canonicalLogicalBindings();
void navigationBudgetHasDistinctRecoverableError();
void capabilityBoundary_data();
void capabilityBoundary();
void commandGrammar();
void commandFailures_data();
void commandFailures();
void numericPageSequence();
void prefixMismatchAndCancellation();
void timeoutAndRepeat();
void textInputAndFocus();
void physicalPageBoundaryAndGeneration();
void stateRoundTripAndPrivacy();
void stateChangedDocumentAndLock();
void stateOpenedIdentityRejectsReplacement();
void stateRestoreDistinguishesChangedIdentity();
void stateSameMetadataReplacement();
void stateLegacyMissingPhysicalIdentity();
void stateBackupRecovery();
void stateLegacySessionUrlMigration_data();
void stateLegacySessionUrlMigration();
void stateRememberCannotReintroduceSessionUrls();
void statePrivacySwitches();
void stateArchiveEntryPrivacyAndRoundTrip();
void stateArchiveEntryIdentityAndCandidates();
void stateUnsafeArchiveEntry_data();
void stateUnsafeArchiveEntry();
void xdgPaths();
};
void CoreTest::defaultConfiguration()
{
ConfigManager config;
QCOMPARE(config.value("ui.theme").toString(), "dark");
QCOMPARE(config.value("ui.toc_visible").toBool(), false);
QCOMPARE(config.value("ui.pages_visible").toBool(), false);
QCOMPARE(config.value("keys.sequence_timeout_ms").toInt(), 800);
QCOMPARE(config.value("privacy.store_text_anchor").toBool(), false);
QVERIFY(config.loadData(""));
QVERIFY(config.snapshot().value("keybindings").toList().size() > 30);
}
void CoreTest::atomicReload()
{
ConfigManager config;
QSignalSpy changes(&config, &ConfigManager::changed);
QVERIFY(config.loadData("[ui]\ntheme='light'\nfont_size=16.5\n[keys]\nsequence_timeout_ms=0\n"));
QCOMPARE(changes.size(), 1);
QCOMPARE(config.value("ui.font_size").toDouble(), 16.5);
const auto valid = config.snapshot();
QVERIFY(!config.loadData("[ui]\ntheme='dark'\nfont_size=500\n"));
QCOMPARE(config.snapshot(), valid);
QCOMPARE(changes.size(), 1);
QVERIFY(config.lastError().contains("3行"));
QVERIFY(!config.loadData("[ui]\nfont_size = \n"));
QCOMPARE(config.snapshot(), valid);
QVERIFY(config.loadData("[ui]\nstatus_bar=false"));
QCOMPARE(config.value("ui.theme").toString(), "dark"); // Each reload is defaults plus one file.
QVERIFY(config.lastError().isEmpty());
}
void CoreTest::reloadKeepsSelectedSource()
{
QTemporaryDir directory;
const auto path = directory.filePath("config.toml");
ConfigManager config;
QVERIFY(!config.reload(path));
QCOMPARE(config.path(), path);
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly));
file.write("[ui]\ntheme='light'");
file.close();
QVERIFY(config.reload());
QCOMPARE(config.value("ui.theme").toString(), "light");
QVERIFY(file.remove());
QVERIFY(!config.reload());
QCOMPARE(config.value("ui.theme").toString(), "light");
}
void CoreTest::invalidConfiguration_data()
{
QTest::addColumn<QByteArray>("toml");
QTest::newRow("future-schema") << QByteArray("schema_version=2");
QTest::newRow("schema-type") << QByteArray("schema_version='1'");
QTest::newRow("unknown-key") << QByteArray("[ui]\nstatusbar=true");
QTest::newRow("unknown-section") << QByteArray("[shell]\ncommand='echo'");
QTest::newRow("integer-type") << QByteArray("[ui]\npanel_width=280.5");
QTest::newRow("boolean-type") << QByteArray("[ui]\nstatus_bar=1");
QTest::newRow("unsupported-filter") << QByteArray("[pdf]\npreserve_colors=false");
QTest::newRow("timeout-lower-bound") << QByteArray("[keys]\nsequence_timeout_ms=100");
QTest::newRow("timeout-upper-bound") << QByteArray("[keys]\nsequence_timeout_ms=3001");
QTest::newRow("non-finite") << QByteArray("[reading]\nfont_size=nan");
QTest::newRow("unknown-command") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['x']\ncommand='shell.exec'");
QTest::newRow("reserved-esc") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Esc']\ncommand='file.open'");
QTest::newRow("reserved-digits") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='global'\nkeys=['4']\ncommand='file.open'");
QTest::newRow("prefix-same-context") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['g']\ncommand='file.open'");
QTest::newRow("prefix-global-context") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='global'\nkeys=['g']\ncommand='file.open'");
QTest::newRow("command-arguments") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['x']\ncommand='nav.page'");
QTest::newRow("input-mode-protected") << QByteArray("[[keybindings]]\nmode='command'\ncontext='global'\nkeys=['j']\ncommand='file.open'");
QTest::newRow("duplicate") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['x']\ncommand='file.open'\n[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['x']\ncommand='app.quit'");
QTest::newRow("duplicate-key-alias") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Shift+j']\ncommand='file.open'\n[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['J']\ncommand='app.quit'");
QTest::newRow("duplicate-modifier") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Ctrl+Ctrl+A']\ncommand='file.open'");
QTest::newRow("ambiguous-shift-symbol") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Shift+1']\ncommand='file.open'");
QTest::newRow("oversize") << QByteArray(1024 * 1024 + 1, ' ');
QTest::newRow("deep-table") << QByteArray("[a.b.c.d.e.f.g.h.i]\nx=1");
QTest::newRow("invalid-utf8") << QByteArray("[ui]\ntheme='\xff'");
}
void CoreTest::invalidConfiguration()
{
QFETCH(QByteArray, toml);
ConfigManager config;
const auto original = config.snapshot();
QVERIFY(!config.loadData(toml));
QVERIFY(!config.lastError().isEmpty());
QCOMPARE(config.snapshot(), original);
}
void CoreTest::bindingPrecedenceAndUnbind()
{
ConfigManager config;
QVERIFY(config.loadData("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['t']\ncommand='document.info'\n"
"[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['g','g']\ncommand='unbound'\n"
"[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['g']\ncommand='file.open'\n"
"[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['x']\ncommand='nav.page'\nargs={page=42}"));
InputRouter router;
router.setConfig(config);
QCOMPARE(router.feed("t").value("command").toString(), "document.info");
QCOMPARE(router.feed("t", "toc").value("command").toString(), "panel.toc.toggle");
QCOMPARE(router.feed("g").value("command").toString(), "file.open");
QCOMPARE(router.feed("x").value("args").toMap().value("page").toInt(), 42);
}
void CoreTest::canonicalLogicalBindings()
{
QCOMPARE(CommandRegistry::canonicalKey("Meta+Shift+Alt+Ctrl+a"), "Ctrl+Alt+Shift+Meta+A");
QCOMPARE(CommandRegistry::canonicalKey("Shift+j"), "J");
QCOMPARE(CommandRegistry::canonicalKey("Ctrl++"), "Ctrl++");
QCOMPARE(CommandRegistry::canonicalKey("Ctrl+Insert"), "Ctrl+Insert");
QCOMPARE(CommandRegistry::canonicalKey("Shift+Tab"), "Shift+Tab");
QCOMPARE(CommandRegistry::canonicalKey("j"), "j");
QCOMPARE(CommandRegistry::canonicalKey("J"), "J");
QVERIFY(CommandRegistry::canonicalKey("Shift+1").isEmpty());
QVERIFY(CommandRegistry::canonicalKey("Ctrl+Ctrl+A").isEmpty());
ConfigManager config;
QVERIFY(config.loadData("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Meta+Shift+Alt+Ctrl+a']\ncommand='document.info'\n"
"[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Shift+j']\ncommand='panel.status.toggle'\n"
"[[keybindings]]\nmode='normal'\ncontext='global'\nkeys=['Ctrl+o']\ncommand='help.toggle'"));
InputRouter router;
router.setConfig(config);
QCOMPARE(router.feed("Ctrl+Alt+Shift+Meta+A").value("command").toString(), "document.info");
QCOMPARE(router.feed("J").value("command").toString(), "panel.status.toggle");
QCOMPARE(router.feed("j").value("command").toString(), "scroll.down");
QCOMPARE(router.feed("Ctrl+O").value("command").toString(), "help.toggle");
const auto good = config.snapshot();
QVERIFY(!config.loadData("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Shift+1']\ncommand='file.open'"));
QVERIFY(config.lastError().contains(QStringLiteral("文字")));
QCOMPARE(config.snapshot(), good);
}
void CoreTest::navigationBudgetHasDistinctRecoverableError()
{
qint64 used = MaxNavigationBytes - 1;
const QVariantList original{QVariantMap{{"title", "previous index"}}};
QVariantList out = original;
QString error;
QVERIFY(!sanitizeNavigation({QVariantMap{{"title", "valid next heading"}, {"page", 2}}}, &out, &used, &error));
QCOMPARE(error, "E_NAVIGATION_LIMIT");
QCOMPARE(used, MaxNavigationBytes - 1);
QCOMPARE(out, original);
QVERIFY(!sanitizeNavigation({QVariantMap{{"title", QString(4097, 'x')}}}, &out, &used, &error));
QVERIFY(error.startsWith("E_WORKER_CRASH"));
QCOMPARE(out, original);
}
void CoreTest::capabilityBoundary_data()
{
QTest::addColumn<QVariant>("capabilities");
QTest::addColumn<QVariant>("reasons");
QTest::addColumn<bool>("valid");
const QVariantMap baseline{{"pageNavigation", "supported"}, {"chapterNavigation", "unsupported"},
{"outline", "unavailable"}, {"headings", "loading"}, {"textSearch", "loading"},
{"textSelection", "unsupported"}, {"reflow", "unsupported"}, {"password", "unavailable"}};
const QVariantMap explanation{{"chapterNavigation", "E_CHAPTER_NAVIGATION_UNSUPPORTED"},
{"textSelection", "E_TEXT_SELECTION_UNSUPPORTED"}, {"reflow", "E_REFLOW_UNSUPPORTED"}};
auto row = [&](const QByteArray &name, const QVariant &caps, const QVariant &why, bool valid = false) {
QTest::newRow(name.constData()) << caps << why << valid;
};
row("all-four-states", baseline, explanation, true);
QVariantMap supported = baseline;
for (auto it = supported.begin(); it != supported.end(); ++it)
it.value() = "supported";
row("supported-needs-no-reason", supported, QVariantMap{}, true);
row("capabilities-map-required", QVariantList{baseline}, explanation);
row("reasons-map-required", baseline, QVariantList{explanation});
for (auto it = baseline.begin(); it != baseline.end(); ++it) {
auto missing = baseline;
missing.remove(it.key());
row("missing-" + it.key().toUtf8(), missing, explanation);
}
auto altered = baseline;
altered["futureCapability"] = "supported";
row("unknown-capability", altered, explanation);
altered = baseline;
altered.remove("headings");
altered["futureCapability"] = "supported";
row("eight-fields-with-unknown-name", altered, explanation);
altered = baseline;
altered["pageNavigation"] = "enabled";
row("unknown-state", altered, explanation);
altered["pageNavigation"] = true;
row("state-type", altered, explanation);
for (auto it = explanation.begin(); it != explanation.end(); ++it) {
auto missing = explanation;
missing.remove(it.key());
row("unsupported-without-" + it.key().toUtf8(), baseline, missing);
}
auto why = explanation;
why["futureCapability"] = "E_UNSUPPORTED";
row("unknown-reason-owner", baseline, why);
for (const auto &invalid : QVariantList{QString{}, QString("E_WITH\nNEWLINE"), QString("explanation"),
QString("E_") + QString(127, 'A'), 42}) {
why = explanation;
why["reflow"] = invalid;
row("invalid-reason-" + QByteArray::number(invalid.toString().size()), baseline, why);
}
}
void CoreTest::capabilityBoundary()
{
QFETCH(QVariant, capabilities);
QFETCH(QVariant, reasons);
QFETCH(bool, valid);
QString error = "previous error";
QCOMPARE(validateCapabilities(capabilities, reasons, &error), valid);
if (valid)
QVERIFY(error.isEmpty());
else
QVERIFY(error.startsWith("E_WORKER_CRASH"));
}
void CoreTest::commandGrammar()
{
const auto open = CommandRegistry::parse(":open \"C:\\Users\\日本語 folder\\book.pdf\"");
QVERIFY2(open.valid(), qPrintable(open.error));
QCOMPARE(open.id, "file.open");
QCOMPARE(open.arguments.value("path").toString(), "C:\\Users\\日本語 folder\\book.pdf");
QCOMPARE(CommandRegistry::parse(":open").arguments.size(), 0);
QCOMPARE(CommandRegistry::parse(":page 42").arguments.value("page").toInt(), 42);
QCOMPARE(CommandRegistry::parse(":rotate -90").arguments.value("degrees").toInt(), -90);
QCOMPARE(CommandRegistry::parse(":history clear").id, "history.clear");
const auto literal = CommandRegistry::parse(":open \"$HOME/$(touch nope)/*.pdf\"");
QVERIFY(literal.valid());
QCOMPARE(literal.arguments.value("path").toString(), "$HOME/$(touch nope)/*.pdf");
}
void CoreTest::commandFailures_data()
{
QTest::addColumn<QString>("command");
for (const auto &text : {"", ":!ls", ":page", ":page 0", ":page -1", ":page 2147483648", ":page 2 3",
":page 2.5", ":rotate 45", ":rotate 0", ":q extra", ":history", ":open \"missing", ":open \"\""})
QTest::newRow(text) << QString::fromUtf8(text);
}
void CoreTest::commandFailures()
{
QFETCH(QString, command);
const auto result = CommandRegistry::parse(command);
QVERIFY(!result.valid());
QVERIFY(!result.error.isEmpty());
}
void CoreTest::numericPageSequence()
{
InputRouter router;
QVERIFY(router.feed("4").isEmpty());
QVERIFY(router.feed("2").isEmpty());
QCOMPARE(router.pending(), "42");
const auto result = router.feed("G");
QCOMPARE(result.value("command").toString(), "nav.page");
QCOMPARE(result.value("args").toMap().value("page").toInt(), 42);
QVERIFY(router.pending().isEmpty());
router.feed("4");
QVERIFY(router.feed("j").isEmpty());
QCOMPARE(router.feed("j").value("command").toString(), "scroll.down");
router.feed("0");
QVERIFY(router.feed("G").isEmpty());
for (int i = 0; i < 15; ++i)
router.feed("9");
QVERIFY(router.feed("G").isEmpty());
QCOMPARE(router.feed("G").value("command").toString(), "nav.document_end");
}
void CoreTest::prefixMismatchAndCancellation()
{
InputRouter router;
QVERIFY(router.feed("g").isEmpty());
QCOMPARE(router.feed("j").value("command").toString(), "scroll.down");
router.feed("g");
QVERIFY(router.feed("Esc").isEmpty());
QVERIFY(router.pending().isEmpty());
router.feed("g");
QCOMPARE(router.feed("g").value("command").toString(), "nav.document_start");
router.feed("Ctrl+W");
QCOMPARE(router.feed("w").value("command").toString(), "focus.next");
router.feed("g");
QVERIFY(router.feed("4").isEmpty());
QCOMPARE(router.pending(), "4");
QCOMPARE(router.feed("G").value("args").toMap().value("page").toInt(), 4);
QCOMPARE(router.feed("Esc", "toc").value("command").toString(), "focus.content");
}
void CoreTest::timeoutAndRepeat()
{
InputRouter router;
router.setBindings(CommandRegistry::defaultBindings(), 20);
router.feed("g");
QTRY_VERIFY_WITH_TIMEOUT(router.pending().isEmpty(), 250);
QVERIFY(router.feed("g").isEmpty());
QVERIFY(router.feed("g", "content", false, true).isEmpty());
QCOMPARE(router.pending(), "g");
router.reset();
QVERIFY(router.feed("t", "content", false, true).isEmpty());
QCOMPARE(router.feed("j", "content", false, true).value("command").toString(), "scroll.down");
router.setBindings(CommandRegistry::defaultBindings(), 0);
router.feed("g");
QTest::qWait(30);
QCOMPARE(router.pending(), "g");
}
void CoreTest::textInputAndFocus()
{
InputRouter router;
for (const auto &key : {"j", "4", "Enter", "Esc", "Ctrl+O"})
QVERIFY(router.feed(key, "content", true).isEmpty());
router.setMode("command");
QVERIFY(router.feed("j").isEmpty());
QVERIFY(router.feed("Ctrl+O").isEmpty());
router.setMode("normal");
router.feed("g");
QVERIFY(router.feed("g", "toc").isEmpty()); // Focus change resets the previous prefix.
QCOMPARE(router.pending(), "g");
QCOMPARE(router.feed("g", "toc").value("command").toString(), "panel.first");
router.feed("4");
QVERIFY(router.feed("Enter", "dialog").isEmpty());
QVERIFY(router.pending().isEmpty());
}
void CoreTest::physicalPageBoundaryAndGeneration()
{
QCOMPARE(physicalPageToIndex(1, 10), std::optional<int>{0});
QCOMPARE(physicalPageToIndex(10, 10), std::optional<int>{9});
QVERIFY(!physicalPageToIndex(11, 10));
QVERIFY(!physicalPageToIndex(0, 10));
QVERIFY(!physicalPageToIndex(std::numeric_limits<std::int64_t>::max(), 10));
QVERIFY(validLocation(PdfLocation{}));
HtmlLocation bad;
bad.progression = std::numeric_limits<double>::quiet_NaN();
QVERIFY(!validLocation(bad));
const ResponseStamp stamp{"document-a", 2, 7};
QVERIFY(stamp.matches({"document-a", 2, 7}));
QVERIFY(!stamp.matches({"document-a", 1, 7}));
QVERIFY(!stamp.matches({"document-b", 2, 7}));
}
static void writeFile(const QString &path, const QByteArray &bytes)
{
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate));
QCOMPARE(file.write(bytes), bytes.size());
}
void CoreTest::stateRoundTripAndPrivacy()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const auto document = directory.filePath("日本語 file.html");
const auto statePath = directory.filePath("state/state.json");
writeFile(document, "<h1>Document</h1>");
{
StateStore state(statePath);
QVERIFY2(!state.readOnly(), qPrintable(state.lastError()));
QVERIFY(state.remember(document, {{"resourcePath", "book.html"}, {"progression", 0.4}, {"textQuote", "private text"}}, 125));
QVERIFY(state.flush());
QCOMPARE(state.recentDocuments().size(), 1);
}
{
StateStore state(statePath);
const auto result = state.restore(document);
QVERIFY(!result.isEmpty());
QCOMPARE(result.value("zoom").toDouble(), 125.0);
QCOMPARE(result.value("location").toMap().value("progression").toDouble(), 0.4);
QVERIFY(!result.value("location").toMap().contains("textQuote"));
QVERIFY(state.clearHistory());
QCOMPARE(state.count(), 0);
QVERIFY(state.restore(document).isEmpty());
QVERIFY(QFileInfo::exists(document));
QVERIFY(!QFileInfo::exists(statePath + ".bak"));
}
}
void CoreTest::stateChangedDocumentAndLock()
{
QTemporaryDir directory;
const auto document = directory.filePath("file.pdf");
const auto statePath = directory.filePath("state.json");
writeFile(document, "old");
StateStore writer(statePath);
QVERIFY(writer.remember(document, {{"pageIndex", 4}}));
QVERIFY(writer.flush());
StateStore reader(statePath);
QVERIFY(reader.readOnly());
QVERIFY(!reader.remember(document, {{"pageIndex", 10}}));
QVERIFY(!reader.clearHistory());
QCOMPARE(reader.restore(document).value("location").toMap().value("pageIndex").toInt(), 4);
writeFile(document, "new content");
QVERIFY(writer.restore(document).isEmpty());
QVERIFY(reader.restore(document).isEmpty());
}
void CoreTest::stateOpenedIdentityRejectsReplacement()
{
QTemporaryDir directory;
const auto path = directory.filePath("book.pdf");
writeFile(path, "original");
const auto opened = StateStore::fileIdentity(path);
StateStore state(directory.filePath("state.json"));
QVERIFY(state.rememberOpened(path, opened, {{"pageIndex", 4}}));
QVERIFY(state.flush());
const auto before = state.recentDocuments().first().toMap();
QVERIFY(QFile::rename(path, path + ".old"));
writeFile(path, "original");
QVERIFY(!state.rememberOpened(path, opened, {{"pageIndex", 42}}));
QVERIFY(state.lastError().startsWith("E_STATE_SAVE:"));
QCOMPARE(state.recentDocuments().first().toMap(), before);
QVERIFY(state.restore(path).isEmpty());
QVERIFY(!state.rememberOpened(path, {}, {{"pageIndex", 42}}));
QVERIFY(state.flush());
StateStore reloaded(state.filePath());
QCOMPARE(reloaded.recentDocuments().first().toMap(), before);
QVERIFY(reloaded.restore(path).isEmpty());
// A fresh explicit open may create new state for B, without A's location.
const auto fresh = StateStore::fileIdentity(path);
QVERIFY(state.rememberOpened(path, fresh, {{"pageIndex", 0}}));
QCOMPARE(state.restore(path).value("location").toMap().value("pageIndex").toInt(), 0);
}
void CoreTest::stateRestoreDistinguishesChangedIdentity()
{
QTemporaryDir directory;
const auto path = directory.filePath("book.html");
writeFile(path, "original");
StateStore state(directory.filePath("state.json"));
const auto opened = StateStore::fileIdentity(path);
QVERIFY(!state.restoreOpened(path, opened).identityMismatch);
QVERIFY(state.rememberOpened(path, opened, {{"progression", .7}}));
QVERIFY(!state.restoreOpened(path, opened).identityMismatch);
writeFile(path, "changed source length");
const auto changed = StateStore::fileIdentity(path);
QVERIFY(state.restoreOpened(path, opened).identityMismatch);
QVERIFY(state.restoreOpened(path, changed).identityMismatch);
QVERIFY(state.restoreOpened(path, changed).record.isEmpty());
ConfigManager config;
QVERIFY(config.loadData("[privacy]\nremember_position=false\n"));
state.setConfig(config);
QVERIFY(!state.restoreOpened(path, changed).identityMismatch);
QVERIFY(state.restoreOpened(path, changed).record.isEmpty());
}
void CoreTest::stateSameMetadataReplacement()
{
QTemporaryDir directory;
const auto document = directory.filePath("same metadata.pdf");
const auto replacement = directory.filePath("replacement.pdf");
writeFile(document, "original");
writeFile(replacement, "replaced");
const auto timestamp = QDateTime::fromMSecsSinceEpoch(1700000000000LL);
for (const auto &path : {document, replacement}) {
QFile file(path); QVERIFY(file.open(QIODevice::ReadWrite));
QVERIFY(file.setFileTime(timestamp, QFileDevice::FileModificationTime));
}
const auto before = StateStore::fileIdentity(document);
QVERIFY(!before.value("fileIdentity").toString().isEmpty());
StateStore state(directory.filePath("state.json"));
QVERIFY(state.remember(document, {{"pageIndex", 41}}));
QVERIFY(state.flush());
const auto oldDocumentId = state.restore(document).value("documentId").toString();
QVERIFY(!oldDocumentId.isEmpty());
// Preserve the old object so filesystems cannot recycle its identifier.
QVERIFY(QFile::rename(document, directory.filePath("previous.pdf")));
QVERIFY(QFile::rename(replacement, document));
const auto after = StateStore::fileIdentity(document);
QCOMPARE(after.value("canonicalPath"), before.value("canonicalPath"));
QCOMPARE(after.value("size"), before.value("size"));
QCOMPARE(after.value("mtime"), before.value("mtime"));
QVERIFY(after.value("fileIdentity") != before.value("fileIdentity"));
QVERIFY(state.restore(document).isEmpty());
QVERIFY(state.remember(document, {{"pageIndex", 0}}));
const auto current = state.restore(document);
QVERIFY(current.value("documentId").toString() != oldDocumentId);
QCOMPARE(current.value("location").toMap().value("pageIndex").toInt(), 0);
QVERIFY(StateStore::fileIdentity(directory.path()).isEmpty());
QVERIFY(StateStore::fileIdentity(directory.filePath("absent.pdf")).isEmpty());
}
void CoreTest::stateLegacyMissingPhysicalIdentity()
{
QTemporaryDir directory;
const auto document = directory.filePath("legacy.pdf");
const auto statePath = directory.filePath("state.json");
writeFile(document, "legacy content");
{
StateStore state(statePath);
QVERIFY(state.remember(document, {{"pageIndex", 8}}));
QVERIFY(state.flush());
}
QFile saved(statePath); QVERIFY(saved.open(QIODevice::ReadOnly));
auto json = QJsonDocument::fromJson(saved.readAll()).object(); saved.close();
auto documents = json.value("documents").toArray();
auto record = documents[0].toObject();
auto identity = record.value("identity").toObject();
identity.insert("fileIdentity", "");
record.insert("identity", identity); documents[0] = record; json.insert("documents", documents);
writeFile(statePath, QJsonDocument(json).toJson(QJsonDocument::Compact));
StateStore state(statePath);
QCOMPARE(state.count(), 1); // Valid schema-v1 history remains readable.
QCOMPARE(state.recentDocuments().size(), 1);
QVERIFY(state.lastError().isEmpty());
QVERIFY(state.restore(document).isEmpty());
QVERIFY(state.remember(document, {{"pageIndex", 0}}));
QCOMPARE(state.restore(document).value("location").toMap().value("pageIndex").toInt(), 0);
QVERIFY(state.restore(document).value("documentId").toString() != record.value("documentId").toString());
}
void CoreTest::stateBackupRecovery()
{
QTemporaryDir directory;
const auto document = directory.filePath("book.pdf");
const auto statePath = directory.filePath("state.json");
writeFile(document, "contents");
{
StateStore state(statePath);
QVERIFY(state.remember(document, {{"pageIndex", 1}}));
QVERIFY(state.flush());
QVERIFY(state.remember(document, {{"pageIndex", 2}}));
QVERIFY(state.flush());
}
QVERIFY(QFileInfo::exists(statePath + ".bak"));
writeFile(statePath, "invalid json");
StateStore recovered(statePath);
QCOMPARE(recovered.restore(document).value("location").toMap().value("pageIndex").toInt(), 1);
QVERIFY(recovered.lastError().contains(QStringLiteral("復元")));
QVERIFY(recovered.clearHistory());
QVERIFY(!QFileInfo::exists(statePath + ".bak"));
}
void CoreTest::statePrivacySwitches()
{
QTemporaryDir directory;
const auto document = directory.filePath("file.html");
writeFile(document, "contents");
StateStore state(directory.filePath("state.json"));
ConfigManager config;
QVERIFY(config.loadData("[privacy]\nremember_position=false\nrecent_documents=0"));
state.setConfig(config);
QVERIFY(state.remember(document, {{"textQuote", "secret"}}));
QCOMPARE(state.count(), 0);
QVERIFY(config.loadData("[privacy]\nstore_text_anchor=true\nrecent_documents=0"));
state.setConfig(config);
QVERIFY(state.remember(document, {{"textQuote", "opted in"}, {"progression", 0.2}}));
QVERIFY(state.recentDocuments().isEmpty());
QCOMPARE(state.restore(document).value("location").toMap().value("textQuote").toString(), "opted in");
}
void CoreTest::stateLegacySessionUrlMigration_data()
{
QTest::addColumn<bool>("damagedCurrent");
QTest::addColumn<bool>("currentUrl");
QTest::addColumn<bool>("readOnly");
QTest::addColumn<bool>("forcedReadOnly");
QTest::newRow("current-and-backup") << false << true << false << false;
QTest::newRow("backup-only-url") << false << false << false << false;
QTest::newRow("backup-fallback") << true << true << false << false;
QTest::newRow("read-only-current") << false << true << true << false;
QTest::newRow("read-only-fallback") << true << true << true << false;
QTest::newRow("forced-read-only-current") << false << true << false << true;
QTest::newRow("forced-read-only-fallback") << true << true << false << true;
}
void CoreTest::stateLegacySessionUrlMigration()
{
QFETCH(bool, damagedCurrent);
QFETCH(bool, currentUrl);
QFETCH(bool, readOnly);
QFETCH(bool, forcedReadOnly);
QTemporaryDir directory;
QVERIFY(directory.isValid());
const auto document = directory.filePath("book.epub");
const auto statePath = directory.filePath("state.json");
writeFile(document, "test document identity");
const auto identity = QJsonObject::fromVariantMap(StateStore::fileIdentity(document));
QVERIFY(!identity.isEmpty());
const QString authored = "A printed example: doc://author-provided-url/chapter";
auto record = [&](double progress, bool transient) {
QJsonObject location{{"href", "OPS/chapter.xhtml"}, {"packagePath", "OPS/book.opf"},
{"spineIdref", "chapter"}, {"cfi", "epubcfi(/4/2:0)"},
{"progression", progress}, {"textQuote", authored}};
if (transient) location.insert("url", "doc://legacy-session-secret/OPS/chapter.xhtml");
const QJsonObject entry{{"documentId", "saved-document-id"}, {"identity", identity},
{"location", location}, {"recent", true}, {"zoom", 100}};
return QJsonDocument(QJsonObject{{"state_version", 1}, {"documents", QJsonArray{entry}}})
.toJson(QJsonDocument::Compact);
};
const QByteArray beforeCurrent = damagedCurrent ? QByteArray("damaged JSON") : record(.7, currentUrl);
const QByteArray beforeBackup = record(.2, true);
writeFile(statePath, beforeCurrent);
writeFile(statePath + ".bak", beforeBackup);
auto bytes = [](const QString &path) { QFile file(path); return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray{}; };
QLockFile otherWriter(statePath + ".lock");
if (readOnly) QVERIFY(otherWriter.tryLock(0));
{
StateStore state(statePath);
// load() must not write before the caller can request read-only mode.
QCOMPARE(bytes(statePath), beforeCurrent);
QCOMPARE(bytes(statePath + ".bak"), beforeBackup);
if (forcedReadOnly) state.forceReadOnly();
QCOMPARE(state.readOnly(), readOnly || forcedReadOnly);
const auto location = state.restore(document).value("location").toMap();
QVERIFY(!location.contains("url"));
QCOMPARE(location.value("progression").toDouble(), damagedCurrent ? .2 : .7);
QCOMPARE(location.value("cfi").toString(), "epubcfi(/4/2:0)");
QCOMPARE(location.value("spineIdref").toString(), "chapter");
QCOMPARE(location.value("textQuote").toString(), authored);
QVERIFY(!state.recentDocuments().first().toMap().value("location").toMap().contains("url"));
QVERIFY(state.flush());
if (damagedCurrent) QVERIFY(state.lastError().contains(QStringLiteral("復元")));
}
if (readOnly || forcedReadOnly) {
QCOMPARE(bytes(statePath), beforeCurrent);
QCOMPARE(bytes(statePath + ".bak"), beforeBackup);
} else {
for (const auto &path : {statePath, statePath + ".bak"}) {
const auto saved = QJsonDocument::fromJson(bytes(path)).object().value("documents").toArray();
QCOMPARE(saved.size(), 1);
const auto location = saved.first().toObject().value("location").toObject();
QVERIFY(!location.contains("url"));
QVERIFY(!bytes(path).contains("legacy-session-secret"));
QCOMPARE(location.value("href").toString(), "OPS/chapter.xhtml");
QCOMPARE(location.value("textQuote").toString(), authored);
QCOMPARE(location.value("progression").toDouble(),
path.endsWith(".bak") || damagedCurrent ? .2 : .7);
}
}
}
void CoreTest::stateRememberCannotReintroduceSessionUrls()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const auto document = directory.filePath("book.html");
const auto statePath = directory.filePath("state.json");
writeFile(document, "test document identity");
StateStore state(statePath);
ConfigManager config;
QVERIFY(config.loadData("[privacy]\nstore_text_anchor=true\n"));
state.setConfig(config);
const QString authored = "Literal doc://printed-example/page in the document";
QVariantMap position{{"href", "book.html"}, {"progression", .3}, {"textQuote", authored},
{"url", "doc://new-session-secret/book.html"}};
QVERIFY(state.remember(document, position));
QVERIFY(!state.restore(document).value("location").toMap().contains("url"));
QVERIFY(state.flush());
auto bytes = [](const QString &path) { QFile file(path); return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray{}; };
QVERIFY(!bytes(statePath).contains("new-session-secret"));
// Simulate an old on-disk version at backup rotation. flush must encode
// validated, sanitized state rather than copying those bytes verbatim.
auto root = QJsonDocument::fromJson(bytes(statePath)).object();
auto documents = root.value("documents").toArray();
auto entry = documents.first().toObject();
auto legacy = entry.value("location").toObject();
legacy.insert("url", "doc://old-session-secret/book.html");
entry.insert("location", legacy); documents[0] = entry; root.insert("documents", documents);
writeFile(statePath, QJsonDocument(root).toJson(QJsonDocument::Compact));
position["progression"] = .8;
QVERIFY(state.remember(document, position));
QVERIFY(state.flush());
for (const auto &path : {statePath, statePath + ".bak"}) {
const auto contents = bytes(path);
QVERIFY(!contents.contains("new-session-secret"));
QVERIFY(!contents.contains("old-session-secret"));
const auto location = QJsonDocument::fromJson(contents).object().value("documents").toArray()
.first().toObject().value("location").toObject();
QVERIFY(!location.contains("url"));
QCOMPARE(location.value("textQuote").toString(), authored);
QCOMPARE(location.value("progression").toDouble(), path.endsWith(".bak") ? .3 : .8);
}
}
void CoreTest::stateArchiveEntryPrivacyAndRoundTrip()
{
QTemporaryDir directory;
const auto document = directory.filePath("book.zip"), statePath = directory.filePath("state.json");
writeFile(document, "unchanged archive");
const QString entry = "chapters/第二章.xhtml";
const QStringList candidates{"first.html", entry};
const auto identity = StateStore::fileIdentity(document);
ConfigManager config;
QVERIFY(config.loadData("[privacy]\nremember_position=false\nrecent_documents=3"));
{
StateStore state(statePath);
state.setConfig(config);
QVERIFY(state.rememberArchiveEntry(document, entry, identity));
QCOMPARE(state.count(), 1);
QCOMPARE(state.archiveEntry(document, candidates), entry);
QVERIFY(state.restore(document).isEmpty());
QVERIFY(state.recentDocuments().first().toMap().value("location").toMap().isEmpty());
QVERIFY(state.flush());
}
StateStore state(statePath);
state.setConfig(config);
QCOMPARE(state.archiveEntry(document, candidates), entry);
QVERIFY(state.remember(document, {{"resourcePath", entry}, {"progression", .6}}));
QCOMPARE(state.archiveEntry(document, candidates), entry);
QVERIFY(state.recentDocuments().first().toMap().value("location").toMap().isEmpty());
QVERIFY(state.flush());
const auto bytes = [](const QString &path) { QFile file(path); return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray{}; };
const auto before = bytes(statePath);
{
StateStore readOnly(statePath);
readOnly.setConfig(config);
QVERIFY(readOnly.readOnly());
QCOMPARE(readOnly.archiveEntry(document, candidates), entry);
QVERIFY(!readOnly.rememberArchiveEntry(document, "first.html", identity));
QVERIFY(!readOnly.clearHistory());
}
QCOMPARE(bytes(statePath), before);
QVERIFY(config.loadData("[privacy]\nremember_position=false\nrecent_documents=0"));
state.setConfig(config);
QVERIFY(state.archiveEntry(document, candidates).isEmpty());
QVERIFY(state.rememberArchiveEntry(document, "first.html", identity));
QVERIFY(state.flush());
QCOMPARE(bytes(statePath), before);
QVERIFY(config.loadData("[privacy]\nremember_position=true\nrecent_documents=0"));
state.setConfig(config);
QCOMPARE(state.archiveEntry(document, candidates), entry);
QVERIFY(state.recentDocuments().isEmpty());
QVERIFY(state.remember(document, {{"resourcePath", entry}, {"progression", .4}}));
QVERIFY(state.rememberArchiveEntry(document, "first.html", identity));
QCOMPARE(state.restore(document).value("location").toMap().value("progression").toDouble(), .4);
QVERIFY(state.flush());
QVERIFY(QFileInfo::exists(statePath + ".bak"));
QVERIFY(state.clearHistory());
QVERIFY(state.archiveEntry(document, candidates).isEmpty());
QCOMPARE(state.count(), 0);
QVERIFY(!QFileInfo::exists(statePath + ".bak"));
}
void CoreTest::stateArchiveEntryIdentityAndCandidates()
{
QTemporaryDir directory;
const auto document = directory.filePath("book.zip");
writeFile(document, "same archive bytes");
const auto identity = StateStore::fileIdentity(document);
StateStore state(directory.filePath("state.json"));
QVERIFY(state.rememberArchiveEntry(document, "second.html", identity));
QVERIFY(state.archiveEntry(document, {"first.html"}).isEmpty());
QCOMPARE(state.archiveEntry(document, {"first.html", "second.html"}), "second.html");
QVERIFY(QFile::rename(document, document + ".original"));
writeFile(document, "same archive bytes");
QFile replacement(document);
QVERIFY(replacement.open(QIODevice::ReadWrite));
QVERIFY(replacement.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime));
replacement.close();
const auto current = StateStore::fileIdentity(document);
QCOMPARE(current.value("size"), identity.value("size"));
QCOMPARE(current.value("mtime"), identity.value("mtime"));
QVERIFY(current.value("fileIdentity") != identity.value("fileIdentity"));
QVERIFY(state.archiveEntry(document, {"first.html", "second.html"}).isEmpty());
QVERIFY(!state.rememberArchiveEntry(document, "first.html", identity));
QVERIFY(state.rememberArchiveEntry(document, "first.html", current));
QCOMPARE(state.archiveEntry(document, {"first.html", "second.html"}), "first.html");
}
void CoreTest::stateUnsafeArchiveEntry_data()
{
QTest::addColumn<QString>("entry");
for (const auto &entry : QStringList{"../outside.html", "/absolute.html", "C:/drive.html", "a\\b.html",
"a//b.html", "a/./b.html", "CON.html", "a/COM¹.htm", "script.js",
"https://example.com/a.html", "a?b.html", "a|b.html", "a/../b.html",
QString::fromLatin1("nul\0byte.html", 13), QString(1024, 'x') + ".html"})
QTest::newRow(entry.toUtf8().toHex().left(60).constData()) << entry;
}
void CoreTest::stateUnsafeArchiveEntry()
{
QFETCH(QString, entry);
QTemporaryDir directory;
const auto document = directory.filePath("book.zip"), statePath = directory.filePath("state.json");
writeFile(document, "archive");
const auto identity = StateStore::fileIdentity(document);
{
StateStore state(statePath);
QVERIFY(!state.rememberArchiveEntry(document, entry, identity));
QCOMPARE(state.count(), 0);
}
// A modified state file cannot turn a saved string into resource authority.
const QJsonObject record{{"documentId", "entry-test"}, {"identity", QJsonObject::fromVariantMap(identity)},
{"location", QJsonObject{}}, {"recent", true}, {"archiveEntry", entry}};
writeFile(statePath, QJsonDocument(QJsonObject{{"state_version", 1}, {"documents", QJsonArray{record}}}).toJson());
StateStore state(statePath);
QCOMPARE(state.count(), 1);
QVERIFY(state.archiveEntry(document, {entry, "safe.html"}).isEmpty());
}
void CoreTest::xdgPaths()
{
#ifdef Q_OS_UNIX
const auto previous = qgetenv("XDG_CONFIG_HOME");
const bool hadValue = qEnvironmentVariableIsSet("XDG_CONFIG_HOME");
qputenv("XDG_CONFIG_HOME", "relative/path");
QCOMPARE(StoragePaths::forCurrentUser().configFile, QDir::homePath() + "/.config/docview/config.toml");
qputenv("XDG_CONFIG_HOME", "/tmp/docview-test-config");
QCOMPARE(StoragePaths::forCurrentUser().configFile, "/tmp/docview-test-config/docview/config.toml");
if (hadValue)
qputenv("XDG_CONFIG_HOME", previous);
else
qunsetenv("XDG_CONFIG_HOME");
#endif
}
QTEST_GUILESS_MAIN(CoreTest)
#include "test_core.moc"