initial commit
This commit is contained in:
@@ -0,0 +1,857 @@
|
||||
#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"
|
||||
Reference in New Issue
Block a user