Merge branch 'Cockatrice:master' into master

This commit is contained in:
Christian Daniel Jensen 2026-11-03 03:39:31 -07:00 • committed by GitHub
commit 482d2dae28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 955 additions and 122 deletions

View file

@ -101,6 +101,36 @@ jobs:
--title "$release_name" \ --title "$release_name" \
--notes-file "$body_path" --notes-file "$body_path"
vcpkg-baseline:
name: vcpkg baseline
runs-on: ubuntu-slim
steps:
- name: "Checkout"
uses: actions/checkout@v7
# vcpkg-tool reads versions/baseline.json out of the commit named by
# `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule
# pointer that lags the manifest - e.g. a stale `vcpkg/` worktree captured by
# `git add` in a PR - makes every vcpkg leg fail in configure with "failed to
# `git show` versions/baseline.json". That reads like a baseline or network
# problem, when the pointer is what has to move. Check the two agree here in
# seconds instead of on five build legs.
- name: "Check the submodule pointer against the manifest baseline"
shell: bash
run: |
pointer="$(git rev-parse HEAD:vcpkg 2>/dev/null || true)"
baseline="$(sed -nE 's/.*"builtin-baseline"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p' vcpkg.json)"
if [[ -z "$pointer" || -z "$baseline" ]]; then
echo "::error file=vcpkg.json::could not read the vcpkg submodule pointer or the builtin-baseline"
exit 1
fi
if [[ "$pointer" != "$baseline" ]]; then
echo "::error file=vcpkg.json::vcpkg submodule is at $pointer but builtin-baseline is $baseline"
echo "Move them together: git -C vcpkg checkout $baseline && git add vcpkg"
exit 1
fi
echo "vcpkg submodule and builtin-baseline agree on $baseline"
build-linux: build-linux:
strategy: strategy:
fail-fast: false fail-fast: false
@ -466,7 +496,7 @@ jobs:
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
needs: configure needs: [configure, vcpkg-baseline]
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
timeout-minutes: 100 timeout-minutes: 100
env: env:

View file

@ -71,6 +71,8 @@ if(WIN32 OR USE_VCPKG)
) )
# Qt path set by user or env var # Qt path set by user or env var
if(QTDIR if(QTDIR
OR QTDIR32
OR QTDIR64
OR DEFINED ENV{QTDIR} OR DEFINED ENV{QTDIR}
OR DEFINED ENV{QTDIR32} OR DEFINED ENV{QTDIR32}
OR DEFINED ENV{QTDIR64} OR DEFINED ENV{QTDIR64}
@ -236,13 +238,13 @@ else()
set(_lib_suffix 32) set(_lib_suffix 32)
endif() endif()
if(DEFINED QTDIR${_lib_suffix}) if(NOT "${QTDIR${_lib_suffix}}" STREQUAL "")
list(APPEND CMAKE_PREFIX_PATH "${QTDIR${_lib_suffix}}") list(APPEND CMAKE_PREFIX_PATH "${QTDIR${_lib_suffix}}")
elseif(DEFINED QTDIR) elseif(NOT "${QTDIR}" STREQUAL "")
list(APPEND CMAKE_PREFIX_PATH "${QTDIR}") list(APPEND CMAKE_PREFIX_PATH "${QTDIR}")
elseif(DEFINED ENV{QTDIR${_lib_suffix}}) elseif(NOT "$ENV{QTDIR${_lib_suffix}}" STREQUAL "")
list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR${_lib_suffix}}") list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR${_lib_suffix}}")
elseif(DEFINED ENV{QTDIR}) elseif(NOT "$ENV{QTDIR}" STREQUAL "")
list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR}") list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR}")
endif() endif()

View file

@ -1,6 +1,6 @@
# Find a compatible Qt version # Find a compatible Qt version
# Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE # Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE
# Optional Input: QT6_DIR -- Hint as to where Qt6 lives on the system # Optional Input: Qt6_DIR -- Directory containing Qt6Config.cmake
# Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt6 # Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt6
# Output: SERVATRICE_QT_MODULES # Output: SERVATRICE_QT_MODULES
# Output: COCKATRICE_QT_MODULES # Output: COCKATRICE_QT_MODULES
@ -57,7 +57,17 @@ find_package(
QUIET HINTS ${Qt6_DIR} QUIET HINTS ${Qt6_DIR}
) )
if(NOT Qt6_FOUND) if(NOT Qt6_FOUND)
message(FATAL_ERROR "No suitable version of Qt was found") set(_QT_DIAGNOSTIC "")
if(Qt6_NOT_FOUND_MESSAGE)
set(_QT_DIAGNOSTIC "\nQt package diagnostic: ${Qt6_NOT_FOUND_MESSAGE}")
endif()
message(FATAL_ERROR
"No suitable version of Qt was found. Qt 6.4.2 or newer with these components is required: "
"${REQUIRED_QT_COMPONENTS};Linguist.\n"
"Set QTDIR or CMAKE_PREFIX_PATH to the Qt installation prefix, or Qt6_DIR to the directory "
"containing Qt6Config.cmake."
"${_QT_DIAGNOSTIC}"
)
endif() endif()
set(COCKATRICE_QT_VERSION_NAME Qt6) set(COCKATRICE_QT_VERSION_NAME Qt6)

View file

@ -15,14 +15,18 @@ static const QString ARCHIDEKT_SUFFIX = "/?format=json";
static const QString MOXFIELD_BASE = "https://api.moxfield.com/v2/decks/all/"; static const QString MOXFIELD_BASE = "https://api.moxfield.com/v2/decks/all/";
static const QString MOXFIELD_SUFFIX = "/"; static const QString MOXFIELD_SUFFIX = "/";
static const QString DECKSTATS_BASE = "https://deckstats.net/decks/";
static const QString DECKSTATS_SUFFIX = "?include_comments=1&export_mtgarena=1"; static const QString DECKSTATS_SUFFIX = "?include_comments=1&export_mtgarena=1";
static const QString MANABOX_BASE = "https://cloud.manabox.app/decks/";
bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo) bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo)
{ {
static QRegularExpression rxTappedOut("tappedout\\.net/(?:mtg-decks/)?([^/?#]+)"); static QRegularExpression rxTappedOut("tappedout\\.net/(?:mtg-decks/)?([^/?#]+)");
static QRegularExpression rxArchidekt("archidekt\\.com/decks/(\\d+)"); static QRegularExpression rxArchidekt("archidekt\\.com/decks/(\\d+)");
static QRegularExpression rxMoxfield("moxfield\\.com/decks/([a-zA-Z0-9_-]+)"); static QRegularExpression rxMoxfield("moxfield\\.com/decks/([a-zA-Z0-9_-]+)");
static QRegularExpression rxDeckstats("deckstats\\.net/decks/(\\d+/[a-zA-Z0-9_-]+)"); static QRegularExpression rxDeckstats("deckstats\\.net/decks/(\\d+/[a-zA-Z0-9_-]+)");
static QRegularExpression rxManabox("manabox\\.app/decks/([a-zA-Z0-9_-]+)");
QRegularExpressionMatch match; QRegularExpressionMatch match;
@ -49,11 +53,18 @@ bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo)
return true; return true;
} else if ((match = rxDeckstats.match(url)).hasMatch()) { } else if ((match = rxDeckstats.match(url)).hasMatch()) {
QString deckPath = match.captured(1); QString deckPath = match.captured(1);
outInfo = ParsedDeckInfo{.baseUrl = "https://deckstats.net/decks/", outInfo = ParsedDeckInfo{.baseUrl = DECKSTATS_BASE,
.deckID = deckPath, .deckID = deckPath,
.fullUrl = "https://deckstats.net/decks/" + deckPath + DECKSTATS_SUFFIX, .fullUrl = DECKSTATS_BASE + deckPath + DECKSTATS_SUFFIX,
.provider = DeckProvider::Deckstats}; .provider = DeckProvider::Deckstats};
return true; return true;
} else if ((match = rxManabox.match(url)).hasMatch()) {
QString deckPath = match.captured(1);
outInfo = ParsedDeckInfo{.baseUrl = MANABOX_BASE,
.deckID = deckPath,
.fullUrl = MANABOX_BASE + deckPath,
.provider = DeckProvider::Manabox};
return true;
} }
return false; return false;

View file

@ -15,6 +15,7 @@ enum class DeckProvider
Archidekt, Archidekt,
Moxfield, Moxfield,
Deckstats, Deckstats,
Manabox,
Unknown Unknown
}; };

View file

@ -8,12 +8,19 @@
#define INTERFACE_JSON_DECK_PARSER_H #define INTERFACE_JSON_DECK_PARSER_H
#include "../../../interface/deck_loader/card_node_function.h" #include "../../../interface/deck_loader/card_node_function.h"
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QList>
#include <QLoggingCategory>
#include <QString>
#include <QTextStream>
#include <libcockatrice/card/import/card_name_normalizer.h> #include <libcockatrice/card/import/card_name_normalizer.h>
#include <libcockatrice/deck_list/deck_list.h> #include <libcockatrice/deck_list/deck_list.h>
inline Q_LOGGING_CATEGORY(InterfaceJsonDeckParserLog, "interface_json_deck_parser");
class IJsonDeckParser class IJsonDeckParser
{ {
public: public:
@ -32,9 +39,6 @@ public:
QString deckName = obj.value("name").toString(); QString deckName = obj.value("name").toString();
QString deckDescription = obj.value("description").toString(); QString deckDescription = obj.value("description").toString();
deckList.setName(deckName);
deckList.setComments(deckDescription);
QString outputText; QString outputText;
QTextStream outStream(&outputText); QTextStream outStream(&outputText);
@ -50,8 +54,14 @@ public:
outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n'; outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n';
} }
outStream.flush();
outStream.seek(0);
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
deckList.setName(deckName);
deckList.setComments(deckDescription);
deckList.forEachCard(CardNodeFunction::ResolveProviderId()); deckList.forEachCard(CardNodeFunction::ResolveProviderId());
deckList.setName(deckName);
deckList.setComments(deckDescription);
return deckList; return deckList;
} }
@ -67,9 +77,6 @@ public:
QString deckName = obj.value("name").toString(); QString deckName = obj.value("name").toString();
QString deckDescription = obj.value("description").toString(); QString deckDescription = obj.value("description").toString();
deckList.setName(deckName);
deckList.setComments(deckDescription);
QString outputText; QString outputText;
QTextStream outStream(&outputText); QTextStream outStream(&outputText);
@ -97,8 +104,14 @@ public:
outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n'; outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n';
} }
outStream.flush();
outStream.seek(0);
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
deckList.setName(deckName);
deckList.setComments(deckDescription);
deckList.forEachCard(CardNodeFunction::ResolveProviderId()); deckList.forEachCard(CardNodeFunction::ResolveProviderId());
deckList.setName(deckName);
deckList.setComments(deckDescription);
QJsonObject commandersObj = obj.value("commanders").toObject(); QJsonObject commandersObj = obj.value("commanders").toObject();
if (!commandersObj.isEmpty()) { if (!commandersObj.isEmpty()) {
@ -114,6 +127,113 @@ public:
} }
} }
// Moxfield stores Oathbreaker signature spells in their own board,
// separate from both mainboard and commanders. They are playable deck
// cards, but must not replace the Oathbreaker as the banner card.
const QJsonObject signatureSpells = obj.value("signatureSpells").toObject();
for (auto it = signatureSpells.begin(); it != signatureSpells.end(); ++it) {
const QJsonObject entry = it.value().toObject();
const QJsonObject card = entry.value("card").toObject();
const QString name = card.value("name").toString();
const int quantity = entry.value("quantity").toInt();
if (name.isEmpty() || quantity <= 0) {
continue;
}
const QString set = card.value("set").toString().toUpper();
const QString collectorNumber = card.value("cn").toString();
const QString providerId = card.value("scryfall_id").toString();
deckList.addCards(name, quantity, DECK_ZONE_MAIN, -1, set, collectorNumber, providerId);
}
return deckList;
}
};
class ManaboxJsonParser : public IJsonDeckParser
{
enum BoardType : quint32
{
MainBoard = 0,
SideBoard = 1,
Commander = 2,
MaybeBoard = 3,
};
public:
DeckList parse(const QJsonObject &obj) override
{
DeckList deckList;
QString deckName = obj.value("name").toString();
QString outputText;
QString mainboardText;
QString sideboardText;
QTextStream mainboardStream(&mainboardText);
QTextStream sideboardStream(&sideboardText);
QTextStream outStream(&outputText);
auto commanderList = QList<QJsonObject>();
auto maybeboardList = QList<QJsonObject>();
for (auto entry : obj.value("cards").toArray()) {
auto card = entry.toObject();
auto collectorNumber = card.value("collectorNumber").toString();
auto cardName = card.value("name").toString();
auto boardType = static_cast<BoardType>(card.value("boardCategory").toInt());
auto setName = card.value("setId").toString();
auto quantity = card.value("quantity").toInt();
auto providerId = card.value("scryfallId").toString();
switch (boardType) {
case MainBoard:
mainboardStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber
<< '\n';
break;
case SideBoard:
sideboardStream << "SB: " << quantity << ' ' << cardName << " (" << setName << ") "
<< collectorNumber << '\n';
break;
case MaybeBoard:
maybeboardList.append(card);
break;
case Commander:
commanderList.append(card);
break;
default:
qCWarning(InterfaceJsonDeckParserLog)
<< "Unknown ManaBox Zone" << card.value("boardCategory").toInt() << "for" << cardName;
break;
}
}
outStream << mainboardText << sideboardText;
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
deckList.setName(deckName);
deckList.forEachCard(CardNodeFunction::ResolveProviderId());
for (const auto &maybeboardCard : maybeboardList) {
QString cardName = maybeboardCard.value("name").toString();
QString setName = maybeboardCard.value("setId").toString().toUpper();
QString collectorNumber = maybeboardCard.value("collectorNumber").toString();
QString providerId = maybeboardCard.value("scryfallId").toString();
for (int x = 0; x < maybeboardCard.value("quantity").toInt(); x++) {
deckList.addCard(cardName, DECK_ZONE_MAYBEBOARD, -1, setName, collectorNumber, providerId);
}
}
for (const auto &commanderCard : commanderList) {
QString commanderName = commanderCard.value("name").toString();
QString setName = commanderCard.value("setId").toString().toUpper();
QString collectorNumber = commanderCard.value("collectorNumber").toString();
QString providerId = commanderCard.value("scryfallId").toString();
deckList.setBannerCard({commanderName, providerId});
deckList.addCard(commanderName, DECK_ZONE_MAIN, -1, setName, collectorNumber, providerId);
}
return deckList; return deckList;
} }
}; };

View file

@ -11,6 +11,7 @@
#include <QSharedPointer> #include <QSharedPointer>
#include <QTimer> #include <QTimer>
#include <QUrl> #include <QUrl>
#include <QtGlobal>
class ExactCard; class ExactCard;
@ -38,32 +39,94 @@ void CardPictureLoaderStatusBar::cleanOldEntries()
if (!loadLog || !loadLog->popup) { if (!loadLog || !loadLog->popup) {
return; return;
} }
for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget :
loadLog->popup->findChildren<CardPictureLoaderRequestStatusDisplayWidget *>()) { const QDateTime now = QDateTime::currentDateTime();
// Removing an entry mutates the log, so walk a copy of it.
const QList<CardPictureLoaderRequestStatusDisplayWidget *> entries = logEntries;
for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : entries) {
statusDisplayWidget->queryElapsedSeconds(); statusDisplayWidget->queryElapsedSeconds();
if (statusDisplayWidget->getFinished() && if (!statusDisplayWidget->getFinished()) {
QDateTime::fromString(statusDisplayWidget->getStartTime()).secsTo(QDateTime::currentDateTime()) > 10) { continue;
loadLog->removeSettingsWidget(statusDisplayWidget); }
progressBar->setMaximum(progressBar->maximum() - 1); if (QDateTime::fromString(statusDisplayWidget->getStartTime()).secsTo(now) > LOG_ENTRY_LIFETIME_SECS) {
progressBar->setValue(progressBar->value() - 1); removeLogEntry(statusDisplayWidget);
} }
} }
} }
void CardPictureLoaderStatusBar::addQueuedImageLoad(const QUrl &url, const ExactCard &card, const QString &setName) void CardPictureLoaderStatusBar::addQueuedImageLoad(const QUrl &url, const ExactCard &card, const QString &setName)
{ {
loadLog->addSettingsWidget(new CardPictureLoaderRequestStatusDisplayWidget(loadLog, url, card, setName)); if (logEntries.size() >= MAX_LOG_ENTRIES) {
trimLog();
}
auto *entry = new CardPictureLoaderRequestStatusDisplayWidget(loadLog, url, card, setName);
loadLog->addSettingsWidget(entry);
logEntries.append(entry);
entriesByUrl.insert(entry->getUrl(), entry);
progressBar->setMaximum(progressBar->maximum() + 1); progressBar->setMaximum(progressBar->maximum() + 1);
} }
void CardPictureLoaderStatusBar::addSuccessfulImageLoad(const QUrl &url) void CardPictureLoaderStatusBar::addSuccessfulImageLoad(const QUrl &url)
{ {
progressBar->setValue(progressBar->value() + 1); const QList<CardPictureLoaderRequestStatusDisplayWidget *> entries = entriesByUrl.values(url.toString());
for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : if (entries.isEmpty()) {
loadLog->popup->findChildren<CardPictureLoaderRequestStatusDisplayWidget *>()) { // The entry may already have been dropped from the log; the download
if (statusDisplayWidget->getUrl() == url.toString()) { // itself still succeeded, there is just nothing left to mark.
statusDisplayWidget->queryElapsedSeconds(); return;
statusDisplayWidget->setFinished(); }
int finishedEntries = 0;
for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : entries) {
if (statusDisplayWidget->getFinished()) {
continue;
}
statusDisplayWidget->queryElapsedSeconds();
statusDisplayWidget->setFinished();
++finishedEntries;
}
// The bar counts the entries currently in the log, so only entries that are
// still listed move it.
progressBar->setValue(progressBar->value() + finishedEntries);
}
void CardPictureLoaderStatusBar::trimLog()
{
if (logEntries.isEmpty()) {
return;
}
// Prefer evicting an entry that has already finished; only fall back to an
// in-flight request when the whole log is still pending.
CardPictureLoaderRequestStatusDisplayWidget *toRemove = nullptr;
for (CardPictureLoaderRequestStatusDisplayWidget *entry : logEntries) {
if (entry->getFinished()) {
toRemove = entry;
break;
} }
} }
}
removeLogEntry(toRemove != nullptr ? toRemove : logEntries.constFirst());
}
void CardPictureLoaderStatusBar::removeLogEntry(CardPictureLoaderRequestStatusDisplayWidget *entry)
{
if (entry == nullptr) {
return;
}
logEntries.removeOne(entry);
entriesByUrl.remove(entry->getUrl(), entry);
// The progress bar's maximum counts the entries currently in the log and
// its value the finished ones, so dropping an entry moves both counters back.
// Move the value first: a maximum of 0 turns the bar into a busy indicator and
// resets its value.
if (entry->getFinished()) {
progressBar->setValue(qMax(0, progressBar->value() - 1));
}
progressBar->setMaximum(qMax(0, progressBar->maximum() - 1));
loadLog->removeSettingsWidget(entry);
}

View file

@ -1,10 +1,13 @@
#ifndef PICTURE_LOADER_STATUS_BAR_H #ifndef PICTURE_LOADER_STATUS_BAR_H
#define PICTURE_LOADER_STATUS_BAR_H #define PICTURE_LOADER_STATUS_BAR_H
#include <QList>
#include <QMultiHash>
#include <QString> #include <QString>
#include <QWidget> #include <QWidget>
#include <qtmetamacros.h> #include <qtmetamacros.h>
class CardPictureLoaderRequestStatusDisplayWidget;
class ExactCard; class ExactCard;
class QHBoxLayout; class QHBoxLayout;
class QProgressBar; class QProgressBar;
@ -21,11 +24,20 @@ class SettingsButtonWidget;
* - Shows overall progress of image downloads. * - Shows overall progress of image downloads.
* - Maintains a log of individual requests via a popup (SettingsButtonWidget). * - Maintains a log of individual requests via a popup (SettingsButtonWidget).
* - Cleans up finished request entries automatically after a delay. * - Cleans up finished request entries automatically after a delay.
*
* The log is a diagnostic view, not a queue: it keeps at most MAX_LOG_ENTRIES
* entries and indexes them by URL, so a burst of requests stays O(1) per event
* instead of scanning every entry the popup holds.
*/ */
class CardPictureLoaderStatusBar : public QWidget class CardPictureLoaderStatusBar : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
/** Entries kept in the log; older ones are evicted as new requests arrive. */
static constexpr int MAX_LOG_ENTRIES = 50;
/** Seconds a finished entry stays in the log before it is cleaned up. */
static constexpr int LOG_ENTRY_LIFETIME_SECS = 10;
/** /**
* @brief Constructs a status bar with progress bar and log button. * @brief Constructs a status bar with progress bar and log button.
* @param parent Parent widget * @param parent Parent widget
@ -55,10 +67,28 @@ public slots:
void cleanOldEntries(); void cleanOldEntries();
private: private:
/**
* @brief Evicts the oldest finished entry (or the oldest entry if none finished)
* until the log fits within MAX_LOG_ENTRIES.
*/
void trimLog();
/**
* @brief Removes one entry from the log, its URL index and the popup, and keeps
* the progress bar counters in step with the entries that remain.
* @param entry Entry to remove
*/
void removeLogEntry(CardPictureLoaderRequestStatusDisplayWidget *entry);
QHBoxLayout *layout; ///< Horizontal layout containing progress bar and log button QHBoxLayout *layout; ///< Horizontal layout containing progress bar and log button
QProgressBar *progressBar; ///< Progress bar showing overall download progress QProgressBar *progressBar; ///< Progress bar showing overall download progress
SettingsButtonWidget *loadLog; ///< Popup log showing individual request statuses SettingsButtonWidget *loadLog; ///< Popup log showing individual request statuses
QTimer *cleaner; ///< Timer for periodically cleaning old log entries QTimer *cleaner; ///< Timer for periodically cleaning old log entries
QList<CardPictureLoaderRequestStatusDisplayWidget *> logEntries; ///< Logged requests, oldest first
// A URL can back several live entries: a request parked for a deferred retry is re-queued while
// its first entry is still pending, so a completion must finish every entry for that URL.
QMultiHash<QString, CardPictureLoaderRequestStatusDisplayWidget *>
entriesByUrl; ///< Log entries indexed by their URL for O(1) completion lookup
}; };
#endif // PICTURE_LOADER_STATUS_BAR_H #endif // PICTURE_LOADER_STATUS_BAR_H

View file

@ -49,6 +49,7 @@
#include <QPushButton> #include <QPushButton>
#include <QSet> #include <QSet>
#include <QSharedPointer> #include <QSharedPointer>
#include <QSignalBlocker>
#include <QSize> #include <QSize>
#include <QSizePolicy> #include <QSizePolicy>
#include <QSplitter> #include <QSplitter>
@ -187,10 +188,17 @@ void DeckEditorDeckDockWidget::createDeckDock()
formatLabel = new QLabel(this); formatLabel = new QLabel(this);
formatComboBox = new QComboBox(this); formatComboBox = new QComboBox(this);
formatComboBox->setObjectName("deckFormatComboBox");
formatComboBox->addItem(tr("Loading Database...")); formatComboBox->addItem(tr("Loading Database..."));
formatComboBox->setEnabled(false); // Disable until loaded formatComboBox->setEnabled(false); // Disable until loaded
commanderBracketWidget = new CommanderBracketWidget(this); commanderBracketWidget = new CommanderBracketWidget(this);
connect(formatComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) {
formatComboBox->setPlaceholderText(QString());
formatComboBox->setToolTip(QString());
deckStateManager->setFormat(index >= 0 ? formatComboBox->itemData(index).toString() : QString());
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
});
commentsLabel = new QLabel(); commentsLabel = new QLabel();
commentsLabel->setObjectName("commentsLabel"); commentsLabel->setObjectName("commentsLabel");
@ -355,6 +363,9 @@ void DeckEditorDeckDockWidget::initializeFormats()
{ {
QStringList allFormats = CardDatabaseManager::query()->getAllFormatsWithCount().keys(); QStringList allFormats = CardDatabaseManager::query()->getAllFormatsWithCount().keys();
// Rebuilding the list is a view update, not a user edit. In particular,
// clear() emits currentIndexChanged(-1), which must not clear the deck.
const QSignalBlocker blocker(formatComboBox);
formatComboBox->clear(); // Remove "Loading Database..." formatComboBox->clear(); // Remove "Loading Database..."
formatComboBox->setEnabled(true); formatComboBox->setEnabled(true);
@ -364,28 +375,25 @@ void DeckEditorDeckDockWidget::initializeFormats()
formatComboBox->addItem(formatName, formatName); // store the raw key in itemData formatComboBox->addItem(formatName, formatName); // store the raw key in itemData
} }
QString format = deckStateManager->getMetadata().gameFormat; syncFormatSelection();
if (!format.isEmpty()) {
formatComboBox->setCurrentIndex(formatComboBox->findData(format));
} else {
// Ensure no selection is visible initially
formatComboBox->setCurrentIndex(-1);
}
connect(formatComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) {
QString formatKey;
if (index >= 0) {
formatKey = formatComboBox->itemData(index).toString();
deckStateManager->setFormat(formatKey);
} else {
deckStateManager->setFormat(""); // clear format if deselected
}
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
});
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
} }
void DeckEditorDeckDockWidget::syncFormatSelection()
{
if (!formatComboBox->isEnabled()) {
return;
}
const QSignalBlocker blocker(formatComboBox);
const QString format = deckStateManager->getMetadata().gameFormat;
const int index = format.isEmpty() ? -1 : formatComboBox->findData(format);
const QString unavailable = index < 0 && !format.isEmpty() ? tr("Unavailable: %1").arg(format) : QString();
formatComboBox->setPlaceholderText(unavailable);
formatComboBox->setToolTip(unavailable);
formatComboBox->setCurrentIndex(index);
}
ExactCard DeckEditorDeckDockWidget::getCurrentCard() ExactCard DeckEditorDeckDockWidget::getCurrentCard()
{ {
QModelIndex current = deckView->selectionModel()->currentIndex(); QModelIndex current = deckView->selectionModel()->currentIndex();
@ -596,9 +604,7 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel()
updatePlaymatLabel(); updatePlaymatLabel();
updateHash(); updateHash();
formatComboBox->blockSignals(true); syncFormatSelection();
formatComboBox->setCurrentIndex(formatComboBox->findData(deckStateManager->getMetadata().gameFormat));
formatComboBox->blockSignals(false);
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
@ -762,18 +768,6 @@ void DeckEditorDeckDockWidget::actSwapSelection()
update(); update();
} }
void DeckEditorDeckDockWidget::actDecrementCard(const ExactCard &card, QString zoneName)
{
if (!card) {
return;
}
if (card.getInfo().getIsToken()) {
zoneName = DECK_ZONE_TOKENS;
}
deckStateManager->decrementCard(card, zoneName);
}
void DeckEditorDeckDockWidget::actDecrementSelection() void DeckEditorDeckDockWidget::actDecrementSelection()
{ {
auto selectedRows = getSelectedCardNodeSourceIndices(); auto selectedRows = getSelectedCardNodeSourceIndices();
@ -1054,6 +1048,7 @@ void DeckEditorDeckDockWidget::retranslateUi()
commentsLabel->setText(tr("&Comments:")); commentsLabel->setText(tr("&Comments:"));
activeGroupCriteriaLabel->setText(tr("Group by:")); activeGroupCriteriaLabel->setText(tr("Group by:"));
formatLabel->setText(tr("Format:")); formatLabel->setText(tr("Format:"));
syncFormatSelection();
commanderBracketWidget->retranslateUi(); commanderBracketWidget->retranslateUi();
hashLabel1->setText(tr("Hash:")); hashLabel1->setText(tr("Hash:"));

View file

@ -70,7 +70,6 @@ public slots:
void syncDisplayWidgetsToModel(); void syncDisplayWidgetsToModel();
void actAddCard(const ExactCard &card, const QString &zoneName); void actAddCard(const ExactCard &card, const QString &zoneName);
void actIncrementSelection(); void actIncrementSelection();
void actDecrementCard(const ExactCard &card, QString zoneName);
void actDecrementSelection(); void actDecrementSelection();
void actSwapCard(const ExactCard &card, const QString &zoneName); void actSwapCard(const ExactCard &card, const QString &zoneName);
void actSwapSelection(); void actSwapSelection();
@ -111,6 +110,7 @@ private:
DeckListModel *getModel() const; DeckListModel *getModel() const;
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement);
void syncFormatSelection();
void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString &currentBoardName); void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString &currentBoardName);
void addChangeBoardMenu(QMenu *menu, const QString &zoneName); void addChangeBoardMenu(QMenu *menu, const QString &zoneName);

View file

@ -211,15 +211,15 @@ QModelIndex DeckStateManager::addCard(const ExactCard &card, const QString &zone
return {}; return {};
} }
QString zone = card.getInfo().getIsToken() ? DECK_ZONE_TOKENS : zoneName; QString targetZone = card.getInfo().getIsToken() ? DECK_ZONE_TOKENS : zoneName;
CardSetPtr set = card.getPrinting().getSet(); CardSetPtr set = card.getPrinting().getSet();
QString setName = set ? set->getCorrectedShortName() : ""; QString setName = set ? set->getCorrectedShortName() : "";
QString reason = QString reason =
tr("Added (%1): %2 (%3) %4").arg(zone, card.getName(), setName, card.getPrinting().getProperty("num")); tr("Added (%1): %2 (%3) %4").arg(targetZone, card.getName(), setName, card.getPrinting().getProperty("num"));
QModelIndex idx = modifyDeck(reason, [&card, &zone](auto model) { return model->addCard(card, zone); }); QModelIndex idx = modifyDeck(reason, [&card, &targetZone](auto model) { return model->addCard(card, targetZone); });
if (idx.isValid()) { if (idx.isValid()) {
emit focusIndexChanged(idx, true); emit focusIndexChanged(idx, true);
@ -234,10 +234,22 @@ QModelIndex DeckStateManager::decrementCard(const ExactCard &card, const QString
return {}; return {};
} }
QString targetZone = zoneName;
QString providerId = card.getPrinting().getUuid(); QString providerId = card.getPrinting().getUuid();
QString collectorNumber = card.getPrinting().getProperty("num"); QString collectorNumber = card.getPrinting().getProperty("num");
QModelIndex idx = deckListModel->findCard(card.getName(), zoneName, providerId, collectorNumber); QModelIndex idx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber);
// Tokens are usually added to the token zone, but it is possible for imported/hand-edited
// decks to contain them in the main or sideboards. This is why we only try to remove the
// token from the token zone if it does not exist in the given zone, allowing us to still
// remove it from there.
if (!idx.isValid() && card.getInfo().getIsToken() && targetZone != DECK_ZONE_TOKENS) {
targetZone = DECK_ZONE_TOKENS;
idx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber);
}
if (!idx.isValid()) { if (!idx.isValid()) {
return {}; return {};
} }
@ -249,7 +261,7 @@ QModelIndex DeckStateManager::decrementCard(const ExactCard &card, const QString
} }
// old index is no longer safe since rows could have been removed // old index is no longer safe since rows could have been removed
QModelIndex newIdx = deckListModel->findCard(card.getName(), zoneName, providerId, collectorNumber); QModelIndex newIdx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber);
if (newIdx.isValid()) { if (newIdx.isValid()) {
emit focusIndexChanged(newIdx, true); emit focusIndexChanged(newIdx, true);
} }

View file

@ -188,17 +188,18 @@ public:
///@} ///@}
/** /**
* @brief Adds the given card to the given zone. * @brief Adds the given card to the given zone. Tokens always go to DECK_ZONE_TOKENS, ignoring the given zone.
* Saves the operation to history if successful. * Saves the operation to history if successful.
* *
* @param card The card to add * @param card The card to add
* @param zoneName The zone to add the card to * @param zoneName The zone to add the card to if the card is not a token
* @return The index of the added card * @return The index of the added card
*/ */
QModelIndex addCard(const ExactCard &card, const QString &zoneName); QModelIndex addCard(const ExactCard &card, const QString &zoneName);
/** /**
* @brief Removes 1 copy of the given card from the given zone. * @brief Removes 1 copy of the given card from the given zone.
* If the card is a token and is not in the given zone, then it is removed from DECK_ZONE_TOKENS instead.
* Saves the operation to history if successful. * Saves the operation to history if successful.
* *
* @param card The card to remove * @param card The card to remove

View file

@ -22,6 +22,7 @@
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QString> #include <QString>
#include <QStringList>
#include <QTextStream> #include <QTextStream>
#include <QUrl> #include <QUrl>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -33,6 +34,7 @@
class QWidget; class QWidget;
static const QStringList SUPPORTED_PROVIDERS = {"Archidekt", "Deckstats", "ManaBox", "Moxfield", "TappedOut"};
DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent) DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent)
{ {
nam = NetworkManagerFactory::create(this); nam = NetworkManagerFactory::create(this);
@ -41,6 +43,7 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent
setLayout(layout); setLayout(layout);
instructionLabel = new QLabel(this); instructionLabel = new QLabel(this);
instructionLabel->setWordWrap(true);
layout->addWidget(instructionLabel); layout->addWidget(instructionLabel);
urlEdit = new QLineEdit(this); urlEdit = new QLineEdit(this);
@ -64,8 +67,8 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent
void DlgLoadDeckFromWebsite::retranslateUi() void DlgLoadDeckFromWebsite::retranslateUi()
{ {
instructionLabel->setText(tr("Paste a link to a decklist site here to import it.\n(Archidekt, Deckstats, Moxfield, " instructionLabel->setText(tr("Paste a link to a decklist site here to import it.") + "\n" +
"and TappedOut are supported.)")); tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", ")));
} }
bool DlgLoadDeckFromWebsite::testValidUrl() bool DlgLoadDeckFromWebsite::testValidUrl()
@ -84,8 +87,8 @@ void DlgLoadDeckFromWebsite::accept()
if (!jsonParser && info.provider != DeckProvider::Deckstats && info.provider != DeckProvider::TappedOut) { if (!jsonParser && info.provider != DeckProvider::Deckstats && info.provider != DeckProvider::TappedOut) {
qCWarning(DlgLoadDeckFromWebsiteLog) << "No parser found for provider"; qCWarning(DlgLoadDeckFromWebsiteLog) << "No parser found for provider";
QMessageBox::warning(this, tr("Load Deck from Website"), QMessageBox::warning(this, tr("Load Deck from Website"),
tr("No parser available for this deck provider.\n (Archidekt, Deckstats, Moxfield, " tr("No parser available for this deck provider.") + "\n" +
"and TappedOut are supported.)")); tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", ")));
QDialog::reject(); QDialog::reject();
return; return;
} }
@ -152,6 +155,7 @@ void DlgLoadDeckFromWebsite::accept()
"https://archidekt.com/decks/9999999\n" "https://archidekt.com/decks/9999999\n"
"https://deckstats.net/decks/99999/9999999-your-deck-name/en\n" "https://deckstats.net/decks/99999/9999999-your-deck-name/en\n"
"https://moxfield.com/decks/XYZxx-XYZ99Yyy-xyzXzzz\n" "https://moxfield.com/decks/XYZxx-XYZ99Yyy-xyzXzzz\n"
"https://manabox.app/decks/XYZxxa-YZ99Yyyx_yzXzzz\n"
"https://tappedout.net/mtg-decks/your-deck-name/")); "https://tappedout.net/mtg-decks/your-deck-name/"));
QDialog::reject(); QDialog::reject();
} }
@ -164,7 +168,9 @@ QSharedPointer<IJsonDeckParser> DlgLoadDeckFromWebsite::createParserForProvider(
return QSharedPointer<IJsonDeckParser>(new ArchidektJsonParser()); return QSharedPointer<IJsonDeckParser>(new ArchidektJsonParser());
case DeckProvider::Moxfield: case DeckProvider::Moxfield:
return QSharedPointer<IJsonDeckParser>(new MoxfieldJsonParser()); return QSharedPointer<IJsonDeckParser>(new MoxfieldJsonParser());
case DeckProvider::Manabox:
return QSharedPointer<IJsonDeckParser>(new ManaboxJsonParser());
default: default:
return QSharedPointer<IJsonDeckParser>(nullptr); return QSharedPointer<IJsonDeckParser>(nullptr);
} }
} }

View file

@ -20,7 +20,6 @@
#include "tab_deck_editor_visual_tab_widget.h" #include "tab_deck_editor_visual_tab_widget.h"
#include <QAction> #include <QAction>
#include <QApplication>
#include <QByteArray> #include <QByteArray>
#include <QDebug> #include <QDebug>
#include <QDockWidget> #include <QDockWidget>
@ -261,17 +260,25 @@ void TabDeckEditorVisual::processMainboardCardClick(const QMouseEvent *event,
} }
} }
/** @brief Get a zone name corresponding to keyboard modifiers. */
static QString zoneForClickModifiers(const Qt::KeyboardModifiers &modifiers)
{
if (modifiers.testFlag(Qt::ControlModifier)) {
return DECK_ZONE_SIDE;
} else if (modifiers.testFlag(Qt::ShiftModifier)) {
return DECK_ZONE_MAYBEBOARD;
} else {
return DECK_ZONE_MAIN;
}
}
/** @brief Handles clicks on cards in the database display. */ /** @brief Handles clicks on cards in the database display. */
void TabDeckEditorVisual::processDatabaseCardClick(const QMouseEvent *event, const ExactCard &card) void TabDeckEditorVisual::processDatabaseCardClick(const QMouseEvent *event, const ExactCard &card)
{ {
if (event->button() == Qt::LeftButton) { if (event->button() == Qt::LeftButton) {
if (QApplication::keyboardModifiers() & Qt::ControlModifier) { addCard(card, zoneForClickModifiers(event->modifiers()));
addCard(card, DECK_ZONE_SIDE);
} else {
addCard(card, DECK_ZONE_MAIN);
}
} else if (event->button() == Qt::RightButton) { } else if (event->button() == Qt::RightButton) {
decrementCard(card, DECK_ZONE_MAIN); decrementCard(card, zoneForClickModifiers(event->modifiers()));
} else if (event->button() == Qt::MiddleButton) { } else if (event->button() == Qt::MiddleButton) {
deckDockWidget->actRemoveCard(); deckDockWidget->actRemoveCard();
} }

View file

@ -130,8 +130,11 @@ void CardDatabaseVisualView::wheelEvent(QWheelEvent *event)
return; return;
} }
// macOS uses transient (overlay) scroll bars that hide themselves
// once they fade out, so we need to use the range to determine if we ignore the wheel when there is nothing to
// scroll. This lets an enclosing scroll area take over.
QScrollBar *bar = verticalScrollBar(); QScrollBar *bar = verticalScrollBar();
if (!bar->isVisible()) { if (bar->maximum() <= bar->minimum()) {
event->ignore(); event->ignore();
return; return;
} }

View file

@ -254,7 +254,11 @@ void MainWindow::actWatchReplay()
void MainWindow::localGameEnded() void MainWindow::localGameEnded()
{ {
delete localServer; // Defer deletion so the LocalClients (already scheduled with deleteLater() in TabSupervisor::stop)
// and any queued commands are processed before the server and its interfaces are destroyed.
if (localServer) {
localServer->deleteLater();
}
localServer = nullptr; localServer = nullptr;
aConnect->setEnabled(true); aConnect->setEnabled(true);

View file

@ -1,6 +1,7 @@
@page editing_decks Editing Decks @page editing_decks Editing Decks
- @subpage editing_decks_classic - @subpage editing_decks_classic
- @subpage editing_decks_visual - @subpage editing_decks_visual
- @subpage editing_decks_printings - @subpage visual_database_display
- @subpage editing_decks_printings

View file

@ -70,4 +70,4 @@ confirm.
The action will automatically import the deck from the online service without any other required user action. The action will automatically import the deck from the online service without any other required user action.
Currently supported services are Archidekt, Deckstats, Moxfield, and TappedOut. Currently supported services are Archidekt, Deckstats, ManaBox, Moxfield, and TappedOut.

View file

@ -0,0 +1,25 @@
@page visual_database_display Visual Database Display
# Purpose
The VisualDatabaseDisplay presents all the cards from the enabled sets in the CardDatabase, providing a visual way to find and add cards to your DeckList.
# Using the Visual Database Display
The VisualDatabaseDisplay can be opened by itself using the Tabs section in the menu bar at the top of the screen, or by selecting the "Visual Database Display" tab in the visual deck editor. When opened as a part of the visual deck editor, the VisualDatabaseDisplay can be used to add and remove cards from your deck. See @ref editing_decks_visual for more information about modifying your deck.
By default the cards are displayed as a grid of pictures. However, a table view using card names can be toggled by pressing the Visual/Table button at the top right of the widget.
# Filtering cards
The top section of the VisualDatabaseDisplay can be used to filter cards based on various card properties, as well as specifying in which way they should be sorted. Filters can be saved and reused at a later time by using the Save/Load button at the right of this panel.
# Modifying the Deck list
When the VisualDatabaseDisplay is opened as part of the visual deck editor, clicking on cards adds or removes them to and from your deck.
In the picture view, left-clicking on a card will add that card to the main board of your deck, while right-clicking will remove that printing of the card from the main board, if it exists. When clicking cards, modifier keys can be used to add (or remove) the card to zones other than the main board. Holding a Ctrl key on Windows and Linux or a Command key on MacOS adds/removes cards to the sideboard, while the Shift keys add/remove cards to the maybeboard.
In the table view, double-clicking on a card adds that card to the main board of your deck, while right-click opens the card menu. Holding the Ctrl key on Windows and Linux or the Command key on MacOS when double-clicking adds the card to the sideboard instead.
Tokens are always added to the token zone, regardless of which view is open or which modifier key is pressed.

View file

@ -423,8 +423,23 @@ DecklistCardNode *DeckList::addCard(const QString &cardName,
const QString &cardProviderId, const QString &cardProviderId,
bool formatLegal) bool formatLegal)
{ {
auto node = return addCards(cardName, 1, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal);
tree.addCard(cardName, 1, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); }
DecklistCardNode *DeckList::addCards(const QString &cardName,
int amount,
const QString &zoneName,
int position,
const QString &cardSetName,
const QString &cardSetCollectorNumber,
const QString &cardProviderId,
bool formatLegal)
{
if (amount <= 0) {
return nullptr;
}
auto node = tree.addCard(cardName, amount, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId,
formatLegal);
refreshDeckHash(); refreshDeckHash();
return node; return node;
} }

View file

@ -284,6 +284,14 @@ public:
const QString &cardSetCollectorNumber = QString(), const QString &cardSetCollectorNumber = QString(),
const QString &cardProviderId = QString(), const QString &cardProviderId = QString(),
const bool formatLegal = true); const bool formatLegal = true);
DecklistCardNode *addCards(const QString &cardName,
int amount,
const QString &zoneName,
int position = -1,
const QString &cardSetName = QString(),
const QString &cardSetCollectorNumber = QString(),
const QString &cardProviderId = QString(),
const bool formatLegal = true);
///@} ///@}
/** @name Deck identity */ /** @name Deck identity */

View file

@ -33,16 +33,18 @@ LocalClient::LocalClient(LocalServerInterface *_lsi,
sendCommand(prepareSessionCommand(joinCmd)); sendCommand(prepareSessionCommand(joinCmd));
} }
LocalClient::~LocalClient()
{
}
void LocalClient::sendCommandContainer(const CommandContainer &cont) void LocalClient::sendCommandContainer(const CommandContainer &cont)
{ {
if (LocalClientLog().isDebugEnabled()) { if (LocalClientLog().isDebugEnabled()) {
qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont); qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont);
} }
if (lsi.isNull()) {
qCWarning(LocalClientLog).noquote()
<< userName << "dropping command, local server interface no longer exists:" << getSafeDebugString(cont);
return;
}
lsi->itemFromClient(cont); lsi->itemFromClient(cont);
} }

View file

@ -10,6 +10,7 @@
#include "../abstract/abstract_client.h" #include "../abstract/abstract_client.h"
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QPointer>
#include <QString> #include <QString>
#include <qtmetamacros.h> #include <qtmetamacros.h>
@ -24,14 +25,15 @@ class LocalClient : public AbstractClient
{ {
Q_OBJECT Q_OBJECT
private: private:
LocalServerInterface *lsi; // Guarded pointer: the LocalServer (and its interfaces) may be destroyed while this client
// still has pending commands queued on the event loop.
QPointer<LocalServerInterface> lsi;
public: public:
LocalClient(LocalServerInterface *_lsi, LocalClient(LocalServerInterface *_lsi,
const QString &_playerName, const QString &_playerName,
const QString &_clientId, const QString &_clientId,
QObject *parent = nullptr); QObject *parent = nullptr);
~LocalClient() override;
void sendCommandContainer(const CommandContainer &cont) override; void sendCommandContainer(const CommandContainer &cont) override;
private slots: private slots:

View file

@ -137,6 +137,7 @@ RemoteClient::~RemoteClient()
void RemoteClient::slotSocketError(QAbstractSocket::SocketError /*error*/) void RemoteClient::slotSocketError(QAbstractSocket::SocketError /*error*/)
{ {
QString errorString = socket->errorString(); QString errorString = socket->errorString();
qCWarning(RemoteClientLog) << "Server connection error:" << errorString;
doDisconnectFromServer(); doDisconnectFromServer();
emit socketError(errorString); emit socketError(errorString);
} }
@ -146,6 +147,7 @@ void RemoteClient::slotWebSocketError(QAbstractSocket::SocketError /*error*/)
QString errorString = websocket->errorString(); QString errorString = websocket->errorString();
if (getStatus() != ClientStatus::StatusDisconnected) { if (getStatus() != ClientStatus::StatusDisconnected) {
qCWarning(RemoteClientLog) << "Server connection error:" << errorString;
doDisconnectFromServer(); doDisconnectFromServer();
emit socketError(errorString); emit socketError(errorString);
} }
@ -153,6 +155,7 @@ void RemoteClient::slotWebSocketError(QAbstractSocket::SocketError /*error*/)
void RemoteClient::slotConnected() void RemoteClient::slotConnected()
{ {
qCInfo(RemoteClientLog) << "Server transport connected";
timeRunning = lastDataReceived = 0; timeRunning = lastDataReceived = 0;
timer->start(); timer->start();
@ -166,6 +169,12 @@ void RemoteClient::slotConnected()
void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentification &event) void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentification &event)
{ {
// Identification arrives before login, including when protocol versions differ.
// Quote the server-supplied strings in the log and limit their size.
const QString serverName = QString::fromStdString(event.server_name()).left(256);
const QString serverVersion = QString::fromStdString(event.server_version()).left(256);
qCInfo(RemoteClientLog) << "Server identification:" << serverName
<< "version" << serverVersion << "protocol version" << event.protocol_version();
if (event.protocol_version() != protocolVersion) { if (event.protocol_version() != protocolVersion) {
emit protocolVersionMismatch(protocolVersion, event.protocol_version()); emit protocolVersionMismatch(protocolVersion, event.protocol_version());
setStatus(StatusDisconnecting); setStatus(StatusDisconnecting);
@ -698,6 +707,8 @@ void RemoteClient::sendCommandContainer(const CommandContainer &cont)
void RemoteClient::connectToHost(const QString &hostname, unsigned int port) void RemoteClient::connectToHost(const QString &hostname, unsigned int port)
{ {
usingWebSocket = port == 443 || port == 80 || port == 4748 || port == 8080; usingWebSocket = port == 443 || port == 80 || port == 4748 || port == 8080;
qCInfo(RemoteClientLog) << "Connecting to server" << hostname << "port" << port
<< (usingWebSocket ? "using WebSocket" : "using TCP");
if (usingWebSocket) { if (usingWebSocket) {
QUrl url(QString("%1://%2:%3/servatrice").arg(port == 443 ? "wss" : "ws").arg(hostname).arg(port)); QUrl url(QString("%1://%2:%3/servatrice").arg(port == 443 ? "wss" : "ws").arg(hostname).arg(port));
websocket->open(url); websocket->open(url);

View file

@ -137,22 +137,22 @@
<message> <message>
<location filename="src/pages.cpp" line="379"/> <location filename="src/pages.cpp" line="379"/>
<source>Downloading (0 MB)</source> <source>Downloading (0 MB)</source>
<translation type="unfinished"/> <translation>Herunterladen (0 MB)</translation>
</message> </message>
<message> <message>
<location filename="src/pages.cpp" line="460"/> <location filename="src/pages.cpp" line="460"/>
<source>Downloading (%1 MB)</source> <source>Downloading (%1 MB)</source>
<translation type="unfinished"/> <translation>Herunterladen (%1 MB)</translation>
</message> </message>
<message> <message>
<location filename="src/pages.cpp" line="509"/> <location filename="src/pages.cpp" line="509"/>
<source>Parsing file (%1%)</source> <source>Parsing file (%1%)</source>
<translation type="unfinished"/> <translation>Datei wird verarbeitet (%1%)</translation>
</message> </message>
<message> <message>
<location filename="src/pages.cpp" line="524"/> <location filename="src/pages.cpp" line="524"/>
<source>Extracting file...</source> <source>Extracting file...</source>
<translation type="unfinished"/> <translation>Extrahiere Datei...</translation>
</message> </message>
<message> <message>
<location filename="src/pages.cpp" line="604"/> <location filename="src/pages.cpp" line="604"/>
@ -168,7 +168,7 @@
<message> <message>
<location filename="src/pages.cpp" line="666"/> <location filename="src/pages.cpp" line="666"/>
<source>Parsing file (100%)</source> <source>Parsing file (100%)</source>
<translation type="unfinished"/> <translation>Datei wird verarbeitet (100%)</translation>
</message> </message>
</context> </context>
<context> <context>
@ -348,7 +348,7 @@
<message> <message>
<location filename="src/pages.cpp" line="797"/> <location filename="src/pages.cpp" line="797"/>
<source>A Cockatrice card database file of %1 MB has been downloaded.</source> <source>A Cockatrice card database file of %1 MB has been downloaded.</source>
<translation type="unfinished"/> <translation>Eine Cockatrice-Kartendatenbankdatei der Größe %1 MB wurde heruntergeladen.</translation>
</message> </message>
<message> <message>
<location filename="src/pages.cpp" line="800"/> <location filename="src/pages.cpp" line="800"/>
@ -434,7 +434,7 @@
<location filename="src/pagetemplates.cpp" line="115"/> <location filename="src/pagetemplates.cpp" line="115"/>
<source>Downloading (0 MB)</source> <source>Downloading (0 MB)</source>
<oldsource>Downloading (0MB)</oldsource> <oldsource>Downloading (0MB)</oldsource>
<translation type="unfinished"/> <translation>Herunterladen (0 MB)</translation>
</message> </message>
<message> <message>
<location filename="src/pagetemplates.cpp" line="129"/> <location filename="src/pagetemplates.cpp" line="129"/>

View file

@ -350,6 +350,18 @@ cockatrice_add_test(
LABELS LABELS
ci ci
) )
# The local game runs one client and one server in the same process.
# Either of them can be destroyed first; the survivor has to stay usable.
cockatrice_add_test(
NAME
local_client_lifetime_test
SOURCES
local_client_lifetime_test.cpp
LIBS
libcockatrice_network
LABELS
ci
)
cockatrice_add_test( cockatrice_add_test(
NAME NAME
server_room_channels_test server_room_channels_test
@ -400,23 +412,6 @@ cockatrice_add_test(
INCLUDE_DIRS INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/cockatrice/src
) )
cockatrice_add_test(
NAME
loader_local_matching_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
${VERSION_STRING_CPP}
loader_local_matching_test.cpp
LIBS
libcockatrice_network
libcockatrice_settings
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
cockatrice_add_test( cockatrice_add_test(
NAME NAME
deck_hash_performance_test deck_hash_performance_test
@ -468,6 +463,19 @@ endif()
# Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt. # Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt.
if(WITH_CLIENT) if(WITH_CLIENT)
cockatrice_add_test(
NAME
moxfield_signature_spells_test
SOURCES
${VERSION_STRING_CPP}
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_loader/card_node_function.cpp
moxfield_signature_spells_test.cpp
LIBS
libcockatrice_card
libcockatrice_deck_list
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
)
cockatrice_add_test( cockatrice_add_test(
NAME NAME
single_instance_manager_test single_instance_manager_test
@ -536,3 +544,46 @@ endif()
if(WITH_ORACLE OR WITH_CLIENT) if(WITH_ORACLE OR WITH_CLIENT)
add_subdirectory(picture_loader_benchmark) add_subdirectory(picture_loader_benchmark)
endif() endif()
# loader_local_matching_test and deck_state_manager_test links libcockatrice_settings,
# which only exists when a client/UI-capable target is being built.
if(WITH_ORACLE OR WITH_CLIENT)
set(SETTINGS_SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
${VERSION_STRING_CPP}
)
set(SETTINGS_LIBS libcockatrice_network libcockatrice_settings)
set(SETTINGS_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src)
cockatrice_add_test(
NAME
loader_local_matching_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
${SETTINGS_SOURCES}
loader_local_matching_test.cpp
LIBS
${SETTINGS_LIBS}
INCLUDE_DIRS
${SETTINGS_INCLUDE_DIRS}
)
cockatrice_add_test(
NAME
deck_state_manager_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp
${SETTINGS_SOURCES}
deck_state_manager_test.cpp
LIBS
libcockatrice_card
libcockatrice_deck_list
libcockatrice_models
libcockatrice_utility
${SETTINGS_LIBS}
INCLUDE_DIRS
${SETTINGS_INCLUDE_DIRS}
)
endif()

View file

@ -0,0 +1,190 @@
/** @file deck_state_manager_test.cpp
* @brief Tests for DeckStateManager operations.
* @ingroup Tests
*/
#include "libcockatrice/card/database/card_database.h"
#include <QCoreApplication>
#include <QDir>
#include <QStandardPaths>
#include <client/settings/cache_settings.h>
#include <deck_list_model.h>
#include <gtest/gtest.h>
#include <interface/widgets/deck_editor/deck_state_manager.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/printing/exact_card.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
namespace
{
/**
* @brief Assert that the DeckStateManager's deck list contains only the given zone,
* and that that zone only contains the given number of cards, all with the given card name.
*
* @param manager the DeckStateManager
* @param restrictToZones In which zones to look for the zone and cards. Empty means use the entire deck
* @param zone The only zone that should exist in the deck
* @param cardName The name of the only card that should exist in the deck
* @param cardNumber The number of cards that should be in the deck
*/
static void containsExactly(const DeckStateManager &manager,
const QSet<QString> &restrictToZones,
QString zone,
QString cardName,
int cardNumber)
{
QList<const InnerDecklistNode *> zones = manager.getDeckList().getZoneNodes(restrictToZones);
ASSERT_EQ(zones.size(), 1);
ASSERT_EQ(zones[0]->getName(), zone);
ASSERT_EQ(zones[0]->size(), 1);
const DecklistCardNode *card = dynamic_cast<const DecklistCardNode *>((*zones[0])[0]);
ASSERT_NE(card, nullptr);
ASSERT_EQ(card->getName(), cardName);
ASSERT_EQ(card->getNumber(), cardNumber);
}
/** @brief Return CardInfo of a token with the given name. */
static CardInfoPtr makeToken(QString name)
{
return CardInfo::newInstance(name, "", true, {}, {}, {}, {}, {});
}
// =====================================================================================================================
// addCard
// =====================================================================================================================
TEST(DeckStateManager, AddCards)
{
DeckStateManager manager;
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1);
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2);
}
TEST(DeckStateManager, AddCardZones)
{
DeckStateManager manager;
manager.addCard(ExactCard(CardInfo::newInstance("Grizzly Bears")), DECK_ZONE_MAIN);
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE);
manager.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), DECK_ZONE_MAYBEBOARD);
// Check if the cards got put into the correct zones.
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 3);
containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Grizzly Bears", 1);
containsExactly(manager, {DECK_ZONE_SIDE}, DECK_ZONE_SIDE, "Lightning Bolt", 1);
containsExactly(manager, {DECK_ZONE_MAYBEBOARD}, DECK_ZONE_MAYBEBOARD, "Swords to Plowshares", 1);
}
TEST(DeckStateManager, AddTokens)
{
DeckStateManager manager;
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE);
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAYBEBOARD);
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS);
// A token should always be put into the tokens zone.
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 4);
}
// =====================================================================================================================
// decrementCard
// =====================================================================================================================
TEST(DeckStateManager, DecrementCards)
{
DeckStateManager manager;
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2);
// Removing from the sideboard should do nothing, as there is nothing in the side board.
manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE);
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2);
// Remove the first card.
manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1);
// Removing the second card should also remove the zone.
manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0);
}
TEST(DeckStateManager, DecrementTokens)
{
DeckStateManager manager;
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE);
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS);
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 3);
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS);
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 2);
// Removing tokens from a given zone should remove them from the
// token zone if the given zone does not contain the token.
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE);
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 1);
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0);
}
TEST(DeckStateManager, DecrementTokenMainboard)
{
DeckStateManager manager;
// Add cards to the deck list directly so tokens are not
// automatically added to the token zone.
manager.getDeckListShared()->addCard("Beast", DECK_ZONE_MAIN);
manager.getDeckListShared()->addCard("Beast", DECK_ZONE_TOKENS);
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 2);
containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Beast", 1);
containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1);
// decrementCard uses the model to find the card, so we need to rebuild it.
manager.getModel()->rebuildTree();
// Tokens should be removed from the main zone first.
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
ASSERT_EQ(manager.getDeckList().getZoneNodes({DECK_ZONE_MAIN}).size(), 0);
containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1);
// If the main zone is empty they can be removed from the token zone.
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0);
}
} // namespace
int main(int argc, char **argv)
{
// Redirect SettingsCache reads/writes (app-data location) away from the real user profile.
QStandardPaths::setTestModeEnabled(true);
QCoreApplication app(argc, argv);
QCoreApplication::setOrganizationName(QStringLiteral("CockatriceTest"));
QCoreApplication::setApplicationName(
QStringLiteral("deck-state-manager-test-%1").arg(QCoreApplication::applicationPid()));
::testing::InitGoogleTest(&argc, argv);
// Add cards to the card database
auto *db = CardDatabaseManager::getInstance();
db->addCard(CardInfo::newInstance("Lightning Bolt"));
db->addCard(CardInfo::newInstance("Grizzly Bears"));
db->addCard(CardInfo::newInstance("Swords to Plowshares"));
db->addCard(makeToken("Beast"));
int result = RUN_ALL_TESTS();
// Clean up the settings directory in ~/.qttest if the tests ran.
if (!::testing::GTEST_FLAG(list_tests)) {
QDir(SettingsCache::instance().getDataPath()).removeRecursively();
QDir(SettingsCache::instance().getCachePath()).removeRecursively();
}
return result;
}

View file

@ -0,0 +1,149 @@
/**
* @file local_client_lifetime_test.cpp
* @brief Tests that a LocalClient survives the death of the LocalServer it talks to.
* @ingroup Tests
*/
#include <QCoreApplication>
#include <QDebug>
#include <QMessageLogContext>
#include <QString>
#include <QStringList>
#include <cstdio>
#include <cstring>
#include <gtest/gtest.h>
#include <libcockatrice/network/client/local/local_client.h>
#include <libcockatrice/network/server/local/local_server.h>
#include <libcockatrice/network/server/local/local_server_interface.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <memory>
// The server library refers to this global; the real applications define it in main.cpp.
RNG_Abstract *rng = nullptr;
namespace
{
// GTest's main() creates no QCoreApplication, which QObject::deleteLater() needs.
const QCoreApplication &application()
{
static int argc = 1;
static char executable[] = "local_client_lifetime_test";
static char *argv[] = {executable, nullptr};
static const QCoreApplication app(argc, argv);
return app;
}
/// Collects the warnings one logging category emits while this object is alive.
///
/// The handler is process-global and Qt does not nest handlers, so only one capture can be
/// installed at a time; GTest running one case at a time is what makes that safe.
class CategoryWarningCapture
{
public:
explicit CategoryWarningCapture(const char *_category) : category(_category)
{
previousHandler = qInstallMessageHandler(captureMessage);
s_active = this;
}
~CategoryWarningCapture()
{
qInstallMessageHandler(previousHandler);
s_active = nullptr;
}
CategoryWarningCapture(const CategoryWarningCapture &) = delete;
CategoryWarningCapture &operator=(const CategoryWarningCapture &) = delete;
const QStringList &warnings() const
{
return capturedWarnings;
}
private:
static void captureMessage(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (s_active != nullptr && type == QtWarningMsg && context.category != nullptr &&
std::strcmp(s_active->category, context.category) == 0) {
s_active->capturedWarnings.append(message);
}
// Forward everything else, so a failure elsewhere in the test is still visible.
if (previousHandler != nullptr) {
previousHandler(type, context, message);
return;
}
std::fprintf(stderr, "%s\n", qPrintable(qFormatLogMessage(type, context, message)));
}
const char *category;
QStringList capturedWarnings;
static CategoryWarningCapture *s_active;
static QtMessageHandler previousHandler;
};
CategoryWarningCapture *CategoryWarningCapture::s_active = nullptr;
QtMessageHandler CategoryWarningCapture::previousHandler = nullptr;
class LocalClientLifetimeTest : public ::testing::Test
{
protected:
void SetUp() override
{
(void)application();
server = std::make_unique<LocalServer>();
LocalServerInterface *lsi = server->newConnection();
ASSERT_NE(lsi, nullptr);
// Building the client logs in and joins room 0 through that interface, exactly as
// MainWindow::startLocalGame() does.
client = std::make_unique<LocalClient>(lsi, QStringLiteral("lifetime-test-player"),
QStringLiteral("lifetime-test-client-id"));
}
void TearDown() override
{
// Either case may already have deleted one of these.
client.reset();
server.reset();
}
std::unique_ptr<LocalServer> server;
std::unique_ptr<LocalClient> client;
};
TEST_F(LocalClientLifetimeTest, SendAfterServerDeletedIsDropped)
{
server.reset(); // also destroys the LocalServerInterface the client was built on
CategoryWarningCapture capture("local_client");
// A default container stands in for a command still queued when the local game ended.
client->sendCommandContainer(CommandContainer());
ASSERT_EQ(capture.warnings().size(), 1);
EXPECT_TRUE(capture.warnings().first().contains(QStringLiteral("dropping command")))
<< capture.warnings().first().toStdString();
// The surviving client is intact: reading its state must not touch the deleted interface.
EXPECT_EQ(client->getUserName(), QStringLiteral("lifetime-test-player"));
}
TEST_F(LocalClientLifetimeTest, DestroyingClientAfterServerIsSafe)
{
server.reset();
CategoryWarningCapture capture("local_client");
client.reset(); // must neither crash nor touch the destroyed interface
// The destructor itself must have been silent.
EXPECT_TRUE(capture.warnings().isEmpty());
// A working capture proves the emptiness above is real rather than a broken handler.
qCWarning(LocalClientLog) << "local_client_lifetime_test capture self-check";
EXPECT_EQ(capture.warnings().size(), 1);
}
} // namespace

View file

@ -0,0 +1,84 @@
#include "client/network/parsers/interface_json_deck_parser.h"
#include <QJsonDocument>
#include <gtest/gtest.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
TEST(MoxfieldImport, SignatureSpellIsAddedToMainWithoutReplacingCommanderBanner)
{
// Moxfield v2 board objects map IDs to entries with quantity and card.
const auto input = QJsonDocument::fromJson(R"json({
"name": "Oathbreaker example", "description": "Notes retained",
"mainboard": {"land": {"quantity": 1, "card": {"name": "Island", "set": "abc", "cn": "1"}}},
"sideboard": {"side": {"quantity": 1, "card": {"name": "Mountain", "set": "abc", "cn": "2"}}},
"commanders": {"walker": {"quantity": 1, "card": {"name": "Jace", "set": "abc", "cn": "3", "scryfall_id": "walker-id"}}},
"signatureSpells": {"spell": {"quantity": 1, "card": {"name": "Lightning Bolt", "set": "xyz", "cn": "4", "scryfall_id": "spell-id"}}}
})json")
.object();
MoxfieldJsonParser parser;
DeckList deck = parser.parse(input);
int mainSpellCount = 0;
int sideSpellCount = 0;
int mainLands = 0;
int sideLands = 0;
deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) {
if (card->getName() == "Island" && zone->getName() == DECK_ZONE_MAIN) {
mainLands += card->getNumber();
}
if (card->getName() == "Mountain" && zone->getName() == DECK_ZONE_SIDE) {
sideLands += card->getNumber();
}
if (card->getName() != "Lightning Bolt") {
return;
}
if (zone->getName() == DECK_ZONE_MAIN) {
mainSpellCount += card->getNumber();
EXPECT_EQ(card->getCardSetShortName(), "XYZ");
EXPECT_EQ(card->getCardCollectorNumber(), "4");
EXPECT_EQ(card->getCardProviderId(), "spell-id");
} else if (zone->getName() == DECK_ZONE_SIDE) {
sideSpellCount += card->getNumber();
}
});
EXPECT_EQ(mainSpellCount, 1);
EXPECT_EQ(sideSpellCount, 0);
EXPECT_EQ(mainLands, 1);
EXPECT_EQ(sideLands, 1);
EXPECT_EQ(deck.getName(), "Oathbreaker example");
EXPECT_EQ(deck.getComments(), "Notes retained");
EXPECT_EQ(deck.getBannerCard().name, "Jace");
EXPECT_EQ(deck.getBannerCard().providerId, "walker-id");
}
TEST(MoxfieldImport, QuantityAwareDeckInsertionRetainsCountAndPrinting)
{
DeckList deck;
auto *card = deck.addCards("Island", 2, DECK_ZONE_MAIN, -1, "ABC", "4", "id");
ASSERT_NE(card, nullptr);
EXPECT_EQ(card->getNumber(), 2);
EXPECT_EQ(card->getCardProviderId(), "id");
EXPECT_EQ(deck.addCards("Island", 0, DECK_ZONE_MAIN), nullptr);
}
TEST(OnlineImport, ArchidektPreservesNameDescriptionAndCards)
{
const auto input = QJsonDocument::fromJson(R"json({
"name": "Online deck", "description": "Deck notes",
"cards": [{"quantity": 2, "card": {
"oracleCard": {"name": "Island"}, "edition": {"editioncode": "abc"}, "collectorNumber": "1"
}}]
})json")
.object();
ArchidektJsonParser parser;
const DeckList deck = parser.parse(input);
int count = 0;
deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) {
if (zone->getName() == DECK_ZONE_MAIN && card->getName() == "Island") {
count += card->getNumber();
}
});
EXPECT_EQ(count, 2);
EXPECT_EQ(deck.getName(), "Online deck");
EXPECT_EQ(deck.getComments(), "Deck notes");
}

2
vcpkg

@ -1 +1 @@
Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489