#include "core/config.h" #include "core/input.h" #include "core/state.h" #include "core/types.h" #include "common/contracts.h" #include #include #include #include #include #include #include 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("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("capabilities"); QTest::addColumn("reasons"); QTest::addColumn("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("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{0}); QCOMPARE(physicalPageToIndex(10, 10), std::optional{9}); QVERIFY(!physicalPageToIndex(11, 10)); QVERIFY(!physicalPageToIndex(0, 10)); QVERIFY(!physicalPageToIndex(std::numeric_limits::max(), 10)); QVERIFY(validLocation(PdfLocation{})); HtmlLocation bad; bad.progression = std::numeric_limits::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, "

Document

"); { 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("damagedCurrent"); QTest::addColumn("currentUrl"); QTest::addColumn("readOnly"); QTest::addColumn("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("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"