Surface spoiler removal reload failures and allow disabled-state retry

This commit is contained in:
Bryce! 2026-11-01 16:36:55 -05:00 • committed by Lukas Brübach
parent 95f4e688e7
commit 7f66cab73a
9 changed files with 344 additions and 140 deletions

View file

@ -15,6 +15,7 @@ set(cockatrice_SOURCES
src/client/network/update/client/client_update_checker.cpp src/client/network/update/client/client_update_checker.cpp
src/client/network/update/client/release_channel.cpp src/client/network/update/client/release_channel.cpp
src/client/network/update/card_spoiler/spoiler_background_updater.cpp src/client/network/update/card_spoiler/spoiler_background_updater.cpp
src/client/network/update/card_spoiler/spoiler_file.cpp
src/client/crash/crash_reporter.cpp src/client/crash/crash_reporter.cpp
src/client/latency_graph_widget.cpp src/client/latency_graph_widget.cpp
src/client/latency_status_widget.cpp src/client/latency_status_widget.cpp

View file

@ -2,22 +2,19 @@
#include "../../../../main.h" #include "../../../../main.h"
#include "../../../settings/cache_settings.h" #include "../../../settings/cache_settings.h"
#include "spoiler_file.h"
#include <QCoreApplication> #include <QCoreApplication>
#include <QCryptographicHash>
#include <QDebug> #include <QDebug>
#include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QFutureWatcher> #include <QFutureWatcher>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QSaveFile>
#include <QSystemTrayIcon> #include <QSystemTrayIcon>
#include <QTimer> #include <QTimer>
#include <QUrl> #include <QUrl>
#include <QXmlStreamReader>
#include <QtConcurrentRun> #include <QtConcurrentRun>
#include <exception> #include <exception>
#include <libcockatrice/card/database/card_database.h> #include <libcockatrice/card/database/card_database.h>
@ -70,7 +67,8 @@ void SpoilerBackgroundUpdater::begin()
return; return;
} }
if (cancelled || operationId != newestOperation || if (cancelled || operationId != newestOperation ||
(!reloadOnly && (!enabledAtStart || !SettingsCache::instance().downloads().getDownloadSpoilersStatus())) || (reloadOnly ? SettingsCache::instance().downloads().getDownloadSpoilersStatus()
: (!enabledAtStart || !SettingsCache::instance().downloads().getDownloadSpoilersStatus())) ||
targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) { targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) {
finish(); finish();
return; return;
@ -97,6 +95,11 @@ void SpoilerBackgroundUpdater::begin()
if (!isCurrent()) { if (!isCurrent()) {
finish(); finish();
} else if (reloadOnly) { } else if (reloadOnly) {
if (QFileInfo::exists(targetPath) && !removeSpoilerFile(targetPath)) {
finish(tr("The spoiler database could not be removed. Check its file permissions and use Reload Card "
"Database to retry."));
return;
}
reloadDatabase(false); reloadDatabase(false);
} else { } else {
downloadFromURL(SPOILERS_STATUS_URL, false); downloadFromURL(SPOILERS_STATUS_URL, false);
@ -106,13 +109,13 @@ void SpoilerBackgroundUpdater::begin()
bool SpoilerBackgroundUpdater::isCurrent() const bool SpoilerBackgroundUpdater::isCurrent() const
{ {
return !completed && !cancelled && currentUpdater == this && operationId == newestOperation && return !completed && !cancelled && currentUpdater == this && operationId == newestOperation &&
(reloadOnly || SettingsCache::instance().downloads().getDownloadSpoilersStatus()) && (reloadOnly != SettingsCache::instance().downloads().getDownloadSpoilersStatus()) &&
targetPath == SettingsCache::instance().paths().getSpoilerCardDatabasePath(); targetPath == SettingsCache::instance().paths().getSpoilerCardDatabasePath();
} }
void SpoilerBackgroundUpdater::cancelIfSettingsChanged() void SpoilerBackgroundUpdater::cancelIfSettingsChanged()
{ {
if ((!reloadOnly && !SettingsCache::instance().downloads().getDownloadSpoilersStatus()) || if (reloadOnly == SettingsCache::instance().downloads().getDownloadSpoilersStatus() ||
targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) { targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) {
cancel(); cancel();
} }
@ -150,8 +153,7 @@ void SpoilerBackgroundUpdater::downloadFromURL(const QUrl &url, bool saveResults
connect(activeReply, &QNetworkReply::finished, this, connect(activeReply, &QNetworkReply::finished, this,
&SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile); &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile);
} else { } else {
connect(activeReply, &QNetworkReply::finished, this, connect(activeReply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled);
&SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled);
} }
} }
@ -204,84 +206,16 @@ void SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile()
} }
spoilerData = reply->readAll(); spoilerData = reply->readAll();
QString error; QString error;
const SaveResult result = saveDownloadedFile(spoilerData, error); const auto result = SpoilerFile::save(targetPath, spoilerData, [this] { return isCurrent(); }, error);
if (result == SaveResult::Failed) { if (result == SpoilerFile::SaveResult::Failed) {
finish(isCurrent() ? error : QString()); finish(isCurrent() ? error : QString());
} else if (result == SaveResult::Written || forceReload) { } else if (result == SpoilerFile::SaveResult::Written || forceReload) {
reloadDatabase(result == SaveResult::Written); reloadDatabase(result == SpoilerFile::SaveResult::Written);
} else { } else {
finish(); finish();
} }
} }
QString SpoilerBackgroundUpdater::validateDownloadedFile(const QByteArray &data) const
{
QXmlStreamReader xml(data);
if (!xml.readNextStartElement() || xml.name() != QStringLiteral("cockatrice_carddatabase")) {
return tr("The download is not a Cockatrice card database. The previous spoiler file was preserved.");
}
bool versionOk = false;
const int version = xml.attributes().value(QStringLiteral("version")).toInt(&versionOk);
if (!versionOk || (version != 3 && version != 4)) {
return tr("The spoiler database uses an unsupported format. The previous spoiler file was preserved.");
}
bool hasCards = false;
int depth = 1;
while (!xml.atEnd()) {
const auto token = xml.readNext();
if (token == QXmlStreamReader::StartElement) {
++depth;
if (depth == 2 && xml.name() == QStringLiteral("cards")) {
hasCards = true;
}
} else if (token == QXmlStreamReader::EndElement) {
--depth;
}
}
if (xml.hasError() || !hasCards) {
return tr("The spoiler database is incomplete or invalid. The previous spoiler file was preserved.");
}
return {};
}
SpoilerBackgroundUpdater::SaveResult SpoilerBackgroundUpdater::saveDownloadedFile(const QByteArray &data,
QString &error)
{
error = validateDownloadedFile(data);
if (!error.isEmpty()) {
return SaveResult::Failed;
}
if (!isCurrent()) {
error = tr("The spoiler update was cancelled because its settings changed.");
return SaveResult::Failed;
}
if (getHash(targetPath) == getHash(data)) {
return SaveResult::Unchanged;
}
const QDir directory = QFileInfo(targetPath).dir();
if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) {
error = tr("Could not create the spoiler database directory: %1").arg(directory.absolutePath());
return SaveResult::Failed;
}
QSaveFile file(targetPath);
file.setDirectWriteFallback(false);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush()) {
error = tr("Could not save the spoiler database to %1: %2. The previous file was preserved.")
.arg(targetPath, file.errorString());
return SaveResult::Failed;
}
if (!isCurrent()) {
file.cancelWriting();
return SaveResult::Failed;
}
if (!file.commit()) {
error = tr("Could not replace the spoiler database at %1: %2. The previous file was preserved.")
.arg(targetPath, file.errorString());
return SaveResult::Failed;
}
return SaveResult::Written;
}
void SpoilerBackgroundUpdater::reloadDatabase(bool updated) void SpoilerBackgroundUpdater::reloadDatabase(bool updated)
{ {
reloading = true; reloading = true;
@ -307,7 +241,10 @@ void SpoilerBackgroundUpdater::reloadDatabase(bool updated)
if (!isCurrent()) { if (!isCurrent()) {
finish(); finish();
} else if (!loaded) { } else if (!loaded) {
finish(tr("Could not reload card data after updating spoilers. Use Update Spoilers to retry the reload.")); finish(reloadOnly
? tr("Could not reload card data after disabling spoilers. Use Reload Card Database to retry.")
: tr("Could not reload card data after updating spoilers. Use Update Spoilers to retry the "
"reload."));
} else { } else {
finish({}, updated); finish({}, updated);
} }
@ -356,32 +293,13 @@ void SpoilerBackgroundUpdater::finish(const QString &error, bool updated)
} }
} }
bool SpoilerBackgroundUpdater::deleteSpoilerFile() SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::deleteSpoilerFile()
{ {
const QString path = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); // Deletion and the following reload share the operation lifetime. Callers can
if (QFileInfo::exists(path) && !removeSpoilerFile(path)) { // observe either failure before the queued operation starts, and an explicit
return false; // retry still reloads when the file has already been removed.
}
// Explicit disable must clear the in-memory view even when another actor has
// already removed the file. Queue behind an older reload through the same
// updater lifecycle, so its final snapshot cannot restore disabled spoilers.
auto *updater = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true); auto *updater = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true);
updater->reloadOnly = true; updater->reloadOnly = true;
connect(updater, &SpoilerBackgroundUpdater::spoilerCheckerDone, updater, &QObject::deleteLater); connect(updater, &SpoilerBackgroundUpdater::spoilerCheckerDone, updater, &QObject::deleteLater);
return true; return updater;
}
QByteArray SpoilerBackgroundUpdater::getHash(const QString fileName)
{
QFile file(fileName);
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
QCryptographicHash hash(QCryptographicHash::Sha256);
return hash.addData(&file) ? hash.result() : QByteArray();
}
QByteArray SpoilerBackgroundUpdater::getHash(QByteArray data)
{
return QCryptographicHash::hash(data, QCryptographicHash::Sha256);
} }

View file

@ -29,9 +29,9 @@ public:
{ {
return "oracle"; return "oracle";
} }
QByteArray getHash(const QString fileName); // Queues removal and reload; the application owns the returned updater and
QByteArray getHash(QByteArray data); // deletes it on completion. Callers can connect its failure/completion signals.
static bool deleteSpoilerFile(); static SpoilerBackgroundUpdater *deleteSpoilerFile();
private slots: private slots:
void begin(); void begin();
@ -40,13 +40,6 @@ private slots:
void cancelIfSettingsChanged(); void cancelIfSettingsChanged();
private: private:
enum class SaveResult
{
Failed,
Unchanged,
Written
};
const bool enabledAtStart; const bool enabledAtStart;
const bool forceReload; const bool forceReload;
const QString targetPath; const QString targetPath;
@ -66,8 +59,6 @@ private:
void finish(const QString &error = {}, bool updated = false); void finish(const QString &error = {}, bool updated = false);
void downloadFromURL(const QUrl &url, bool saveResults); void downloadFromURL(const QUrl &url, bool saveResults);
void reloadDatabase(bool updated); void reloadDatabase(bool updated);
QString validateDownloadedFile(const QByteArray &data) const;
SaveResult saveDownloadedFile(const QByteArray &data, QString &error);
signals: signals:
void spoilersUpdatedSuccessfully(); void spoilersUpdatedSuccessfully();

View file

@ -0,0 +1,109 @@
#include "spoiler_file.h"
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QXmlStreamReader>
namespace
{
QByteArray hashFile(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
QCryptographicHash hash(QCryptographicHash::Sha256);
return hash.addData(&file) ? hash.result() : QByteArray();
}
} // namespace
namespace SpoilerFile
{
QString validate(const QByteArray &data)
{
QXmlStreamReader xml(data);
if (!xml.readNextStartElement() || xml.name() != QStringLiteral("cockatrice_carddatabase")) {
return QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"The download is not a Cockatrice card database. The previous spoiler file was preserved.");
}
bool versionOk = false;
const int version = xml.attributes().value(QStringLiteral("version")).toInt(&versionOk);
if (!versionOk || (version != 3 && version != 4)) {
return QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"The spoiler database uses an unsupported format. The previous spoiler file was preserved.");
}
bool hasCards = false;
int depth = 1;
while (!xml.atEnd()) {
const auto token = xml.readNext();
if (token == QXmlStreamReader::StartElement) {
++depth;
if (depth == 2 && xml.name() == QStringLiteral("cards")) {
hasCards = true;
}
} else if (token == QXmlStreamReader::EndElement) {
--depth;
}
}
if (xml.hasError() || !hasCards) {
return QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"The spoiler database is incomplete or invalid. The previous spoiler file was preserved.");
}
return {};
}
SaveResult
save(const QString &targetPath, const QByteArray &data, const std::function<bool()> &isCurrent, QString &error)
{
error = validate(data);
if (!error.isEmpty()) {
return SaveResult::Failed;
}
if (!isCurrent()) {
error = QCoreApplication::translate("SpoilerBackgroundUpdater",
"The spoiler update was cancelled because its settings changed.");
return SaveResult::Failed;
}
if (hashFile(targetPath) == QCryptographicHash::hash(data, QCryptographicHash::Sha256)) {
return SaveResult::Unchanged;
}
const QDir directory = QFileInfo(targetPath).dir();
if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) {
error = QCoreApplication::translate("SpoilerBackgroundUpdater",
"Could not create the spoiler database directory: %1")
.arg(directory.absolutePath());
return SaveResult::Failed;
}
QSaveFile file(targetPath);
file.setDirectWriteFallback(false);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush()) {
error = QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"Could not save the spoiler database to %1: %2. The previous file was preserved.")
.arg(targetPath, file.errorString());
return SaveResult::Failed;
}
if (!isCurrent()) {
file.cancelWriting();
error = QCoreApplication::translate("SpoilerBackgroundUpdater",
"The spoiler update was cancelled because its settings changed.");
return SaveResult::Failed;
}
if (!file.commit()) {
error = QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"Could not replace the spoiler database at %1: %2. The previous file was preserved.")
.arg(targetPath, file.errorString());
return SaveResult::Failed;
}
return SaveResult::Written;
}
} // namespace SpoilerFile

View file

@ -0,0 +1,24 @@
#ifndef COCKATRICE_SPOILER_FILE_H
#define COCKATRICE_SPOILER_FILE_H
#include <QByteArray>
#include <QString>
#include <functional>
namespace SpoilerFile
{
enum class SaveResult
{
Failed,
Unchanged,
Written
};
QString validate(const QByteArray &data);
// Validate before opening the destination, compare complete files, and replace
// atomically. Check operation ownership both before writing and before committing.
SaveResult
save(const QString &targetPath, const QByteArray &data, const std::function<bool()> &isCurrent, QString &error);
} // namespace SpoilerFile
#endif // COCKATRICE_SPOILER_FILE_H

View file

@ -62,7 +62,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
connect(mpSpoilerPathButton, &QPushButton::clicked, this, &DeckEditorSettingsPage::spoilerPathButtonClicked); connect(mpSpoilerPathButton, &QPushButton::clicked, this, &DeckEditorSettingsPage::spoilerPathButtonClicked);
updateNowButton = new QPushButton; updateNowButton = new QPushButton;
updateNowButton->setFixedWidth(150); updateNowButton->setMinimumWidth(150);
connect(updateNowButton, &QPushButton::clicked, this, &DeckEditorSettingsPage::updateSpoilers); connect(updateNowButton, &QPushButton::clicked, this, &DeckEditorSettingsPage::updateSpoilers);
// Update the GUI depending on if the box is ticked or not // Update the GUI depending on if the box is ticked or not
@ -329,21 +329,28 @@ void DeckEditorSettingsPage::urlListChanged(const QModelIndex &, int, int, const
void DeckEditorSettingsPage::updateSpoilers() void DeckEditorSettingsPage::updateSpoilers()
{ {
// Disable the button so the user can only press it once at a time if (spoilerUpdater) {
updateNowButton->setDisabled(true); return;
updateNowButton->setText(tr("Updating...")); }
// Application ownership lets either operation finish after settings closes.
// When disabled, the same button retries file removal and the local reload.
auto *sbu = SettingsCache::instance().downloads().getDownloadSpoilersStatus()
? new SpoilerBackgroundUpdater(QCoreApplication::instance(), true)
: SpoilerBackgroundUpdater::deleteSpoilerFile();
observeSpoilerUpdater(sbu);
}
// The explicit update also retries a previous reload of an unchanged file. void DeckEditorSettingsPage::observeSpoilerUpdater(SpoilerBackgroundUpdater *sbu)
// Application ownership lets it finish if the settings page is closed. {
auto *sbu = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true);
spoilerUpdater = sbu; spoilerUpdater = sbu;
unlockSettings();
connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, this, [this, sbu] { connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, this, [this, sbu] {
if (spoilerUpdater == sbu) { if (spoilerUpdater == sbu) {
spoilerUpdater.clear(); spoilerUpdater.clear();
unlockSettings(); unlockSettings();
} }
}); });
connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, sbu, &QObject::deleteLater); connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, sbu, &QObject::deleteLater, Qt::UniqueConnection);
connect(sbu, &SpoilerBackgroundUpdater::spoilerUpdateFailed, this, [this, sbu](const QString &message) { connect(sbu, &SpoilerBackgroundUpdater::spoilerUpdateFailed, this, [this, sbu](const QString &message) {
if (spoilerUpdater != sbu) { if (spoilerUpdater != sbu) {
return; return;
@ -357,8 +364,11 @@ void DeckEditorSettingsPage::updateSpoilers()
void DeckEditorSettingsPage::unlockSettings() void DeckEditorSettingsPage::unlockSettings()
{ {
updateNowButton->setEnabled(SettingsCache::instance().downloads().getDownloadSpoilersStatus()); updateNowButton->setEnabled(!spoilerUpdater);
updateNowButton->setText(tr("Update Spoilers")); updateNowButton->setText(spoilerUpdater ? tr("Updating...")
: SettingsCache::instance().downloads().getDownloadSpoilersStatus()
? tr("Update Spoilers")
: tr("Reload Card Database"));
lastUpdatedLabel.setText(tr("Last Change") + ": " + getLastUpdateTime()); lastUpdatedLabel.setText(tr("Last Change") + ": " + getLastUpdateTime());
} }
@ -397,22 +407,13 @@ void DeckEditorSettingsPage::setSpoilersEnabled(bool anInput)
mpSpoilerSavePathLineEdit->setEnabled(anInput); mpSpoilerSavePathLineEdit->setEnabled(anInput);
mpSpoilerPathButton->setEnabled(anInput); mpSpoilerPathButton->setEnabled(anInput);
lastUpdatedLabel.setEnabled(anInput); lastUpdatedLabel.setEnabled(anInput);
updateNowButton->setEnabled(anInput && !spoilerUpdater);
infoOnSpoilersLabel.setEnabled(anInput); infoOnSpoilersLabel.setEnabled(anInput);
if (!anInput && (sender() == &mcDownloadSpoilersCheckBox || if (!anInput && (sender() == &mcDownloadSpoilersCheckBox ||
QFileInfo::exists(SettingsCache::instance().paths().getSpoilerCardDatabasePath()))) { QFileInfo::exists(SettingsCache::instance().paths().getSpoilerCardDatabasePath()))) {
if (!SpoilerBackgroundUpdater::deleteSpoilerFile()) { observeSpoilerUpdater(SpoilerBackgroundUpdater::deleteSpoilerFile());
auto *notice = new QMessageBox(QMessageBox::Warning, tr("Spoiler update"),
tr("The spoiler database could not be removed. Check its file permissions "
"and try disabling spoilers again."),
QMessageBox::Ok, this);
notice->setTextFormat(Qt::PlainText);
notice->setAttribute(Qt::WA_DeleteOnClose);
notice->open();
}
} }
lastUpdatedLabel.setText(tr("Last Change") + ": " + getLastUpdateTime()); unlockSettings();
} }
void DeckEditorSettingsPage::retranslateUi() void DeckEditorSettingsPage::retranslateUi()
@ -428,7 +429,7 @@ void DeckEditorSettingsPage::retranslateUi()
picDownloadCheckBox.setText(tr("Download card pictures on the fly")); picDownloadCheckBox.setText(tr("Download card pictures on the fly"));
urlLinkLabel.setText(QString("<a href='%1'>%2</a>").arg(WIKI_CUSTOM_PIC_URL).arg(tr("How to add a custom URL"))); urlLinkLabel.setText(QString("<a href='%1'>%2</a>").arg(WIKI_CUSTOM_PIC_URL).arg(tr("How to add a custom URL")));
resetDownloadURLs.setText(tr("Reset Download URLs")); resetDownloadURLs.setText(tr("Reset Download URLs"));
updateNowButton->setText(tr("Update Spoilers")); unlockSettings();
aAdd->setText(tr("Add New URL")); aAdd->setText(tr("Add New URL"));
aEdit->setText(tr("Edit URL")); aEdit->setText(tr("Edit URL"));
aRemove->setText(tr("Remove URL")); aRemove->setText(tr("Remove URL"));

View file

@ -69,6 +69,8 @@ private:
/** @brief Refreshes the displayed label of every URL item after limits or settings change. */ /** @brief Refreshes the displayed label of every URL item after limits or settings change. */
void refreshUrlItems(); void refreshUrlItems();
void observeSpoilerUpdater(SpoilerBackgroundUpdater *updater);
}; };
#endif // COCKATRICE_DECK_EDITOR_SETTINGS_PAGE_H #endif // COCKATRICE_DECK_EDITOR_SETTINGS_PAGE_H

View file

@ -190,6 +190,15 @@ cockatrice_add_test(
${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/cockatrice/src
${CMAKE_SOURCE_DIR}/libcockatrice_network ${CMAKE_SOURCE_DIR}/libcockatrice_network
) )
cockatrice_add_test(
NAME
spoiler_file_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp
spoiler_file_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
# The installer's holder probe is Windows-only, but the wording it produces and the # The installer's holder probe is Windows-only, but the wording it produces and the
# question which programs are the payload's own are plain C++ and are what a stuck user # question which programs are the payload's own are plain C++ and are what a stuck user
# has to act on, so they are tested everywhere. # has to act on, so they are tested everywhere.

149
tests/spoiler_file_test.cpp Normal file
View file

@ -0,0 +1,149 @@
#include "client/network/update/card_spoiler/spoiler_file.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QTemporaryDir>
#include <gtest/gtest.h>
#ifdef Q_OS_UNIX
#include <csignal>
#include <sys/resource.h>
#include <unistd.h>
#endif
namespace
{
class SpoilerFileTest : public ::testing::Test
{
protected:
QTemporaryDir directory;
QString path;
QString error;
const QByteArray original = "<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>";
void SetUp() override
{
ASSERT_TRUE(directory.isValid());
path = directory.filePath("spoiler.xml");
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
ASSERT_EQ(file.write(original), original.size());
}
QByteArray read()
{
QFile file(path);
EXPECT_TRUE(file.open(QIODevice::ReadOnly));
return file.readAll();
}
SpoilerFile::SaveResult save(const QByteArray &data)
{
return SpoilerFile::save(path, data, [] { return true; }, error);
}
};
TEST_F(SpoilerFileTest, InvalidDocumentsPreserveExistingFile)
{
for (const QByteArray &invalid :
{QByteArray(), QByteArray("<html><cards/></html>"),
QByteArray("<cockatrice_carddatabase version=\"2\"><cards/></cockatrice_carddatabase>"),
QByteArray("<cockatrice_carddatabase><cards/></cockatrice_carddatabase>"),
QByteArray("<cockatrice_carddatabase version=\"4\"><sets/></cockatrice_carddatabase>"),
QByteArray("<cockatrice_carddatabase version=\"4\"><cards>"),
QByteArray("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase><extra/>")}) {
EXPECT_EQ(save(invalid), SpoilerFile::SaveResult::Failed);
EXPECT_FALSE(error.isEmpty());
EXPECT_EQ(read(), original);
}
}
TEST_F(SpoilerFileTest, SupportedVersionsAreAccepted)
{
EXPECT_TRUE(SpoilerFile::validate(original).isEmpty());
EXPECT_TRUE(
SpoilerFile::validate("<cockatrice_carddatabase version=\"3\"><cards/></cockatrice_carddatabase>").isEmpty());
}
TEST_F(SpoilerFileTest, IdenticalFileIsUnchanged)
{
EXPECT_EQ(save(original), SpoilerFile::SaveResult::Unchanged);
EXPECT_TRUE(error.isEmpty());
EXPECT_EQ(read(), original);
}
TEST_F(SpoilerFileTest, CompleteFileHashDetectsChangesAfterLargeCommonPrefix)
{
QByteArray changed = "<cockatrice_carddatabase version=\"4\"><!--";
changed += QByteArray(100000, 'x');
changed += "--><cards/></cockatrice_carddatabase>";
ASSERT_EQ(save(changed), SpoilerFile::SaveResult::Written);
changed.replace("<cards/>", "<cards></cards>");
EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Written);
EXPECT_EQ(read(), changed);
EXPECT_TRUE(error.isEmpty());
}
TEST_F(SpoilerFileTest, CancellationBeforeWritePreservesExistingFile)
{
EXPECT_EQ(SpoilerFile::save(path, original, [] { return false; }, error), SpoilerFile::SaveResult::Failed);
EXPECT_FALSE(error.isEmpty());
EXPECT_EQ(read(), original);
}
TEST_F(SpoilerFileTest, CancellationBeforeCommitPreservesExistingFile)
{
int checks = 0;
EXPECT_EQ(SpoilerFile::save(
path, original + "\n", [&checks] { return ++checks == 1; }, error),
SpoilerFile::SaveResult::Failed);
EXPECT_EQ(checks, 2);
EXPECT_FALSE(error.isEmpty());
EXPECT_EQ(read(), original);
EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"});
}
TEST_F(SpoilerFileTest, MissingParentIsCreated)
{
path = directory.filePath("nested/spoiler.xml");
EXPECT_EQ(save(original), SpoilerFile::SaveResult::Written);
EXPECT_EQ(read(), original);
}
TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt)
{
EXPECT_EQ(SpoilerFile::save(
QDir(path).filePath("spoiler.xml"), original, [] { return true; }, error),
SpoilerFile::SaveResult::Failed);
EXPECT_FALSE(error.isEmpty());
EXPECT_EQ(read(), original);
}
#ifdef Q_OS_UNIX
TEST_F(SpoilerFileTest, ShortWriteLeavesExistingFileIntact)
{
// Limit only the child process: a real short write exercises QSaveFile's
// buffered flush failure without depending on disk capacity or file ownership.
const QByteArray changed = "<cockatrice_carddatabase version=\"4\"><!--" + QByteArray(100000, 'x') +
"--><cards/></cockatrice_carddatabase>";
EXPECT_EXIT(
{
struct rlimit limit;
if (getrlimit(RLIMIT_FSIZE, &limit) != 0) {
_exit(2);
}
limit.rlim_cur = 128;
if (setrlimit(RLIMIT_FSIZE, &limit) != 0) {
_exit(3);
}
std::signal(SIGXFSZ, SIG_IGN);
const auto result = save(changed);
_exit(result == SpoilerFile::SaveResult::Failed && !error.isEmpty() ? 0 : 1);
},
::testing::ExitedWithCode(0), "");
EXPECT_EQ(read(), original);
EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"});
}
#endif
} // namespace