diff --git a/format.sh b/format.sh index ca3557ea7..3fa435be1 100755 --- a/format.sh +++ b/format.sh @@ -22,6 +22,7 @@ libcockatrice_* \ exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ "oracle/src/lzma/" \ +"oracle/src/qt-json/" \ "oracle/src/zip/" \ "servatrice/src/smtp/") exts=("cpp" "h" "proto") diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 6a29b6935..0736db7f5 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -23,6 +23,7 @@ set(oracle_SOURCES src/pages.cpp src/pagetemplates.cpp src/parsehelpers.cpp + src/qt-json/json.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index 49577baf0..fdb32bb8d 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -3,12 +3,10 @@ #include "libcockatrice/interfaces/noop_card_preference_provider.h" #include "libcockatrice/interfaces/noop_card_set_priority_controller.h" #include "parsehelpers.h" +#include "qt-json/json.h" #include -#include -#include #include -#include #include #include #include @@ -46,30 +44,29 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s bool OracleImporter::readSetsFromByteArray(const QByteArray &data) { - QJsonParseError error; - auto doc = QJsonDocument::fromJson(data, &error); - if (error.error != QJsonParseError::NoError) { - qDebug() << "error: QJsonDocument::fromJson():" << error.errorString(); + bool ok; + auto setsMap = QtJson::Json::parse(QString(data), ok).toMap().value("data").toMap(); + if (!ok) { + qDebug() << "error: QtJson::Json::parse()"; return false; } - auto setsObj = doc.object().value("data").toObject(); - QList newSetList; - for (auto it = setsObj.constBegin(); it != setsObj.constEnd(); ++it) { - QJsonObject setObj = it.value().toObject(); - QString shortName = setObj.value("code").toString().toUpper(); - QString longName = setObj.value("name").toString(); - QJsonArray setCards = setObj.value("cards").toArray(); - QString setType = setObj.value("type").toString(); - QDate releaseDate = QDate::fromString(setObj.value("releaseDate").toString(), Qt::ISODate); + QListIterator it(setsMap.values()); + + while (it.hasNext()) { + QVariantMap map = it.next().toMap(); + QString shortName = map.value("code").toString().toUpper(); + QString longName = map.value("name").toString(); + QList setCards = map.value("cards").toList(); + QString setType = map.value("type").toString(); + QDate releaseDate = map.value("releaseDate").toDate(); CardSet::Priority priority = getSetPriority(setType, shortName); // capitalize set type if (setType.length() > 0) { // basic grammar for words that aren't capitalized, like in "From the Vault" - static const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", - "of", "in", "and", "with", "or"}; + const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", "of", "in", "and", "with", "or"}; QStringList words = setType.split("_"); setType.clear(); bool first = false; @@ -77,7 +74,7 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) if (first && noCapitalize.contains(item)) { setType += item + QString(" "); } else { - setType += item[0].toUpper() + item.mid(1) + QString(" "); + setType += item[0].toUpper() + item.mid(1, -1) + QString(" "); first = true; } } @@ -125,8 +122,14 @@ static void sortAndReduceColors(QString &colors) std::sort(colors.begin(), colors.end(), [](const QChar a, const QChar b) { return colorOrder.value(a, INT_MAX) < colorOrder.value(b, INT_MAX); }); // reduce - auto last = std::unique(colors.begin(), colors.end()); - colors.erase(last, colors.end()); + QChar lastChar = '\0'; + for (int i = 0; i < colors.size(); ++i) { + if (colors.at(i) == lastChar) { + colors.remove(i, 1); + } else { + lastChar = colors.at(i); + } + } } CardInfoPtr OracleImporter::addCard(QString name, @@ -139,12 +142,9 @@ CardInfoPtr OracleImporter::addCard(QString name, // Workaround for card name weirdness name = name.replace("Æ", "AE"); name = name.replace("’", "'"); - auto existingIt = cards.constFind(name); - if (existingIt != cards.constEnd()) { - CardInfoPtr card = existingIt.value(); + if (cards.contains(name)) { + CardInfoPtr card = cards.value(name); card->addToSet(printingInfo.getSet(), printingInfo); - // Only merge legalities when the card has none yet, so multi-format - // printings don't overwrite each other's legality lists. if (card->getProperties().filter(formatRegex).empty()) { card->combineLegalities(properties); } @@ -182,9 +182,8 @@ CardInfoPtr OracleImporter::addCard(QString name, // DETECT CARD POSITIONING INFO - QString layoutVal = properties.value("layout"); - bool landscapeOrientation = - properties.value("maintype") == "Battle" || layoutVal == "split" || layoutVal == "planar"; + bool landscapeOrientation = properties.value("maintype") == "Battle" || properties.value("layout") == "split" || + properties.value("layout") == "planar"; // cards that enter the field tapped bool cipt = parseCipt(name, text) || landscapeOrientation; @@ -223,15 +222,12 @@ CardInfoPtr OracleImporter::addCard(QString name, return newCard; } -static QString getJsonString(const QJsonObject &obj, const QString &key) +static QString getStringPropertyFromMap(const QVariantMap &card, const QString &propertyName) { - // QVariant coerces numbers and booleans to text, while QJsonValue::toString() - // returns a null string for them — some MTGJSON fields (manaValue, - // convertedManaCost, isOnlineOnly, isRebalanced) carry those types. - return obj.value(key).toVariant().toString(); + return card.contains(propertyName) ? card.value(propertyName).toString() : QString(""); } -int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList) +int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList) { // mtgjson name => xml name static const QMap cardProperties{ @@ -252,7 +248,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson static const QString ptSeparator = "/"; static constexpr bool isToken = false; - static const QSet setsWithCardsWithSameNameButDifferentText = {"UST"}; + static const QList setsWithCardsWithSameNameButDifferentText = {"UST"}; int numCards = 0; @@ -260,16 +256,16 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson QMap, QString>> splitCards; // Keeps track of all names encountered so far - QSet allNameProps; + QList allNameProps; - for (const QJsonValue &cardVal : cardsList) { - QJsonObject card = cardVal.toObject(); + for (const QVariant &cardVar : cardsList) { + QVariantMap card = cardVar.toMap(); /* Currently used layouts are: * augment, double_faced_token, flip, host, leveler, meld, normal, planar, * saga, scheme, split, token, transform, vanguard */ - QString layout = getJsonString(card, "layout"); + QString layout = getStringPropertyFromMap(card, "layout"); // don't import tokens from the json file if (layout == "token") { @@ -277,9 +273,9 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } // normal cards handling - QString name = getJsonString(card, "name"); - QString text = getJsonString(card, "text"); - QString faceName = getJsonString(card, "faceName"); + QString name = getStringPropertyFromMap(card, "name"); + QString text = getStringPropertyFromMap(card, "text"); + QString faceName = getStringPropertyFromMap(card, "faceName"); if (faceName.isEmpty()) { faceName = name; } @@ -287,34 +283,39 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // card properties QHash properties; for (auto i = cardProperties.cbegin(), end = cardProperties.cend(); i != end; ++i) { - QString propertyValue = getJsonString(card, i.key()); + QString mtgjsonProperty = i.key(); + QString xmlPropertyName = i.value(); + QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); if (!propertyValue.isEmpty()) { - properties.insert(i.value(), propertyValue); + properties.insert(xmlPropertyName, propertyValue); } } // per-set properties QHash printingProps; for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) { - QString propertyValue = getJsonString(card, i.key()); + QString mtgjsonProperty = i.key(); + QString xmlPropertyName = i.value(); + QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); if (!propertyValue.isEmpty()) { - printingProps.insert(i.value(), propertyValue); + printingProps.insert(xmlPropertyName, propertyValue); } } // handle flavorNames specially due to double-faced cards - QString faceFlavorName = getJsonString(card, "faceFlavorName"); - QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getJsonString(card, "flavorName"); + QString faceFlavorName = getStringPropertyFromMap(card, "faceFlavorName"); + QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getStringPropertyFromMap(card, "flavorName"); if (!flavorName.isEmpty()) { printingProps.insert("flavorName", flavorName); } // Identifiers - QJsonObject identifiers = card.value("identifiers").toObject(); for (auto i = identifierProperties.cbegin(), end = identifierProperties.cend(); i != end; ++i) { - QString propertyValue = getJsonString(identifiers, i.key()); + QString mtgjsonProperty = i.key(); + QString xmlPropertyName = i.value(); + QString propertyValue = getStringPropertyFromMap(card.value("identifiers").toMap(), mtgjsonProperty); if (!propertyValue.isEmpty()) { - printingProps.insert(i.value(), propertyValue); + printingProps.insert(xmlPropertyName, propertyValue); } } @@ -330,26 +331,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson allNameProps.contains(faceName) && layout == "normal" && lastChar.isLetter()) { numComponent = " (" + QString(lastChar).toLower() + ")"; } - allNameProps.insert(faceName); + allNameProps.append(faceName); // special handling properties - QString colors; - for (const QJsonValue &color : card.value("colors").toArray()) { - colors += color.toString(); - } + QString colors = card.value("colors").toStringList().join(""); if (!colors.isEmpty()) { properties.insert("colors", colors); } - QString colorIdentity; - for (const QJsonValue &color : card.value("colorIdentity").toArray()) { - colorIdentity += color.toString(); - } + // special handling properties + QString colorIdentity = card.value("colorIdentity").toStringList().join(""); if (!colorIdentity.isEmpty()) { properties.insert("coloridentity", colorIdentity); } - const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList()); + const auto &mainCardType = getMainCardType(card.value("types").toStringList()); if (mainCardType.isEmpty()) { qDebug() << "warning: no mainCardType for card:" << name; } else { @@ -358,22 +354,22 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // Depending on whether power and/or toughness are present, the format // is either P/T (most common), P (no toughness), or /T (no power). - QString power = getJsonString(card, "power"); - QString toughness = getJsonString(card, "toughness"); + QString power = getStringPropertyFromMap(card, "power"); + QString toughness = getStringPropertyFromMap(card, "toughness"); if (toughness.isEmpty() && !power.isEmpty()) { properties.insert("pt", power); } else if (!toughness.isEmpty()) { properties.insert("pt", power + ptSeparator + toughness); } - auto legalities = card.value("legalities").toObject(); - for (auto i = legalities.constBegin(), end = legalities.constEnd(); i != end; ++i) { + auto legalities = card.value("legalities").toMap(); + for (auto i = legalities.cbegin(), end = legalities.cend(); i != end; ++i) { properties.insert(QString("format-%1").arg(i.key()), i.value().toString().toLower()); } // split cards are considered a single card, enqueue for later merging if (layout == "split" || layout == "aftermath" || layout == "adventure" || layout == "prepare") { - auto _faceName = getJsonString(card, "faceName"); + auto _faceName = getStringPropertyFromMap(card, "faceName"); SplitCardPart split(_faceName, text, properties, printingInfo); auto found_iter = splitCards.find(name + numProperty); if (found_iter == splitCards.end()) { @@ -386,11 +382,11 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson QList relatedCards; // add other face for split cards as card relation - if (!getJsonString(card, "side").isEmpty()) { - auto faceManaValue = getJsonString(card, "faceManaValue"); + if (!getStringPropertyFromMap(card, "side").isEmpty()) { + auto faceManaValue = getStringPropertyFromMap(card, "faceManaValue"); if (faceManaValue.isEmpty()) { // check the old name for the property, for backwards compatibility purposes - faceManaValue = getJsonString(card, "faceConvertedManaCost"); + faceManaValue = getStringPropertyFromMap(card, "faceConvertedManaCost"); } properties["cmc"] = faceManaValue; @@ -410,21 +406,20 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson name = faceName; } - // mtgjson related cards - QJsonObject givenRelated = card.value("relatedCards").toObject(); - if (!givenRelated.isEmpty()) { + // mtgjon related cards + if (card.contains("relatedCards")) { + QVariantMap givenRelated = card.value("relatedCards").toMap(); // conjured cards from a spellbook - QJsonArray spellbook = givenRelated.value("spellbook").toArray(); - if (!spellbook.isEmpty()) { - for (const QJsonValue &spbkVal : spellbook) { - relatedCards.append(new CardRelation(spbkVal.toString(), CardRelationType::DoesNotAttach, false, - false, 1, true)); + if (givenRelated.contains("spellbook")) { + auto spbk = givenRelated.value("spellbook").toStringList(); + for (const QString &spbkName : spbk) { + relatedCards.append( + new CardRelation(spbkName, CardRelationType::DoesNotAttach, false, false, 1, true)); } } } - CardInfoPtr newCard = - addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo); + CardInfoPtr newCard = addCard(name + numComponent, text, isToken, properties, relatedCards, printingInfo); numCards++; } } @@ -432,6 +427,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // split cards handling static const QString splitCardPropSeparator = QString(" // "); static const QString splitCardTextSeparator = QString("\n\n---\n\n"); + static const QList noRelatedCards = {}; QList, QString>> partsAndNames = splitCards.values(); for (auto [splitCardParts, name] : partsAndNames) { @@ -457,7 +453,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson if (!thisCardPropertyValue.isEmpty() && originalPropertyValue != thisCardPropertyValue) { if (originalPropertyValue.isEmpty()) { // don't create //es if one field is empty properties.insert(prop, thisCardPropertyValue); - } else if (prop == "colors" || prop == "coloridentity") { // the card is both colors + } else if (prop == "colors") { // the card is both colors properties.insert(prop, originalPropertyValue + thisCardPropertyValue); } else if (prop == "maintype") { // don't create maintypes with //es in them continue; @@ -469,20 +465,20 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } } - CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo); + CardInfoPtr newCard = addCard(name, text, isToken, properties, noRelatedCards, printingInfo); numCards++; } return numCards; } -static FormatRulesNameMap buildDefaultMagicFormats() +FormatRulesNameMap OracleImporter::createDefaultMagicFormats() { // Predefined common exceptions CardCondition superTypeIsBasic; superTypeIsBasic.field = "type"; superTypeIsBasic.matchType = "regex"; - superTypeIsBasic.value = R"(\bBasic\b[^—]+\bLand\b)"; + superTypeIsBasic.value = "\bBasic\b[^—]+\bLand\b"; ExceptionRule basicLands; basicLands.conditions.append(superTypeIsBasic); @@ -495,6 +491,7 @@ static FormatRulesNameMap buildDefaultMagicFormats() ExceptionRule mayContainAnyNumber; mayContainAnyNumber.conditions.append(anyNumberAllowed); + // Map to store default rules FormatRulesNameMap defaultFormatRulesNameMap; // ----------------- Helper lambda to create format ----------------- @@ -540,33 +537,10 @@ static FormatRulesNameMap buildDefaultMagicFormats() return defaultFormatRulesNameMap; } -const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats() -{ - static const FormatRulesNameMap cached = buildDefaultMagicFormats(); - return cached; -} - int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); - // Pre-allocate the cards hash to avoid rehashing during import. The hash - // is keyed by distinct card name rather than by printings: AllPrintings - // ships ~100k printings for ~35k names, so reserving the printing count - // would overallocate ~3x (against this stack's RAM goal). Collecting - // distinct names is cheap — one pass over the already-parsed name fields. - { - QSet distinctNames; - for (const SetToDownload &curSetToParse : allSets) { - for (const QJsonValue &cardValue : curSetToParse.getCards()) { - distinctNames.insert(cardValue.toObject().value("name").toString()); - } - } - cards.reserve(distinctNames.size()); - // The set goes out of scope here, handing the ~35k name QStrings back - // to the allocator before the (memory-heavy) import loop starts. - } - // add an empty set for tokens CardSetPtr tokenSet = CardSet::newInstance(noOpController, CardSet::TOKENS_SETNAME, tr("Dummy set containing tokens"), "Tokens"); @@ -602,11 +576,6 @@ bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUr return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion); } -void OracleImporter::releaseSetData() -{ - allSets.clear(); -} - void OracleImporter::clear() { sets.clear(); diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 52a7cd349..99644f9ce 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -1,8 +1,6 @@ #ifndef ORACLEIMPORTER_H #define ORACLEIMPORTER_H -#include -#include #include #include #include @@ -46,7 +44,7 @@ class SetToDownload { private: QString shortName, longName; - QJsonArray cards; + QList cards; QDate releaseDate; QString setType; CardSet::Priority priority; @@ -60,7 +58,7 @@ public: { return longName; } - const QJsonArray &getCards() const + const QList &getCards() const { return cards; } @@ -78,7 +76,7 @@ public: } SetToDownload(QString _shortName, QString _longName, - QJsonArray _cards, + QList _cards, CardSet::Priority _priority, QString _setType = QString(), const QDate &_releaseDate = QDate()) @@ -156,11 +154,8 @@ public: bool readSetsFromByteArray(const QByteArray &data); int startImport(); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); - int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); - /** - * @brief Returns the default format rules. The result is memoized on first use and must be treated as immutable. - */ - const FormatRulesNameMap &createDefaultMagicFormats(); + int importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList); + FormatRulesNameMap createDefaultMagicFormats(); const CardNameMap &getCardList() const { return cards; @@ -169,7 +164,6 @@ public: { return allSets; } - void releaseSetData(); void clear(); }; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index 94e662ffe..df4d1a98c 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -560,9 +560,6 @@ void SaveSetsPage::initializePage() int setsImported = wizard()->importer->startImport(); - // JSON data no longer needed after CardInfo objects are built - wizard()->importer->releaseSetData(); - if (setsImported == 0) { QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); } diff --git a/oracle/src/qt-json/AUTHORS b/oracle/src/qt-json/AUTHORS new file mode 100644 index 000000000..29a85929f --- /dev/null +++ b/oracle/src/qt-json/AUTHORS @@ -0,0 +1,3 @@ +Eeli Reilin +Luis Gustavo S. Barreto +Stephen Kockentiedt diff --git a/oracle/src/qt-json/LICENSE b/oracle/src/qt-json/LICENSE new file mode 100644 index 000000000..3c42b515a --- /dev/null +++ b/oracle/src/qt-json/LICENSE @@ -0,0 +1,27 @@ +Copyright 2011 Eeli Reilin. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation +are those of the authors and should not be interpreted as representing +official policies, either expressed or implied, of Eeli Reilin. + diff --git a/oracle/src/qt-json/README b/oracle/src/qt-json/README new file mode 100644 index 000000000..b60c1599b --- /dev/null +++ b/oracle/src/qt-json/README @@ -0,0 +1,96 @@ +######################################################################## +1. INTRODUCTION + +The Json class is a simple class for parsing JSON data into a QVariant +hierarchies. Now, we can also reverse the process and serialize +QVariant hierarchies into valid JSON data. + + +######################################################################## +2. HOW TO USE + +The parser is really easy to use. Let's say we have the following +QString of JSON data: + +------------------------------------------------------------------------ +{ + "encoding" : "UTF-8", + "plug-ins" : [ + "python", + "c++", + "ruby" + ], + "indent" : { + "length" : 3, + "use_space" : true + } +} +------------------------------------------------------------------------ + +We would first call the parse-method: + +------------------------------------------------------------------------ +//Say that we're using the QtJson namespace +using namespace QtJson; +bool ok; +//json is a QString containing the JSON data +QVariantMap result = Json::parse(json, ok).toMap(); + +if(!ok) { + qFatal("An error occurred during parsing"); + exit(1); +} +------------------------------------------------------------------------ + +Assuming the parsing process completed without errors, we would then +go through the hierarchy: + +------------------------------------------------------------------------ +qDebug() << "encoding:" << result["encoding"].toString(); +qDebug() << "plugins:"; + +foreach(QVariant plugin, result["plug-ins"].toList()) { + qDebug() << "\t-" << plugin.toString(); +} + +QVariantMap nestedMap = result["indent"].toMap(); +qDebug() << "length:" << nestedMap["length"].toInt(); +qDebug() << "use_space:" << nestedMap["use_space"].toBool(); +------------------------------------------------------------------------ + +The previous code would print out the following: + +------------------------------------------------------------------------ +encoding: "UTF-8" +plugins: + - "python" + - "c++" + - "ruby" +length: 3 +use_space: true +------------------------------------------------------------------------ + +To write JSON data from Qt object is as simple as parsing: + +------------------------------------------------------------------------ +QVariantMap map; +map["name"] = "Name"; +map["age"] = 22; + +QByteArray data = Json::serialize(map); +------------------------------------------------------------------------ + +The byte array 'data' contains valid JSON data: + +------------------------------------------------------------------------ +{ + name: "Luis Gustavo", + age: 22, +} +------------------------------------------------------------------------ + + +######################################################################## +4. CONTRIBUTING + +The code is available to download at GitHub. Contribute if you dare! diff --git a/oracle/src/qt-json/json.cpp b/oracle/src/qt-json/json.cpp new file mode 100644 index 000000000..ff739b49d --- /dev/null +++ b/oracle/src/qt-json/json.cpp @@ -0,0 +1,545 @@ +/* Copyright 2011 Eeli Reilin. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation + * are those of the authors and should not be interpreted as representing + * official policies, either expressed or implied, of Eeli Reilin. + */ + +/** + * \file json.cpp + */ + +#include "json.h" + +#include +#include + +namespace QtJson +{ + +static QString sanitizeString(QString str) +{ + str.replace(QLatin1String("\\"), QLatin1String("\\\\")); + str.replace(QLatin1String("\""), QLatin1String("\\\"")); + str.replace(QLatin1String("\b"), QLatin1String("\\b")); + str.replace(QLatin1String("\f"), QLatin1String("\\f")); + str.replace(QLatin1String("\n"), QLatin1String("\\n")); + str.replace(QLatin1String("\r"), QLatin1String("\\r")); + str.replace(QLatin1String("\t"), QLatin1String("\\t")); + return QString(QLatin1String("\"%1\"")).arg(str); +} + +static QByteArray join(const QList &list, const QByteArray &sep) +{ + QByteArray res; + for (const QByteArray &i : list) { + if (!res.isEmpty()) { + res += sep; + } + res += i; + } + return res; +} + +/** + * parse + */ +QVariant Json::parse(const QString &json) +{ + bool success = true; + return Json::parse(json, success); +} + +/** + * parse + */ +QVariant Json::parse(const QString &json, bool &success) +{ + success = true; + + // Return an empty QVariant if the JSON data is either null or empty + if (!json.isNull() || !json.isEmpty()) { + // We'll start from index 0 + int index = 0; + + // Parse the first value + QVariant value = Json::parseValue(json, index, success); + + // Return the parsed value + return value; + } else { + // Return the empty QVariant + return QVariant(); + } +} + +QByteArray Json::serialize(const QVariant &data) +{ + bool success = true; + return Json::serialize(data, success); +} + +QByteArray Json::serialize(const QVariant &data, bool &success) +{ + QByteArray str; + success = true; + + if (!data.isValid()) // invalid or null? + { + str = "null"; + } + else if ((data.typeId() == QMetaType::Type::QVariantList) || + (data.typeId() == QMetaType::Type::QStringList)) // variant is a list? + { + QList values; + const QVariantList list = data.toList(); + for (const QVariant &v : list) { + QByteArray serializedValue = serialize(v); + if (serializedValue.isNull()) { + success = false; + break; + } + values << serializedValue; + } + + str = "[ " + join(values, ", ") + " ]"; + } + else if ((data.typeId() == QMetaType::Type::QVariantHash)) // variant is a hash? + { + const QVariantHash vhash = data.toHash(); + QHashIterator it(vhash); + str = "{ "; + QList pairs; + + while (it.hasNext()) { + it.next(); + QByteArray serializedValue = serialize(it.value()); + + if (serializedValue.isNull()) { + success = false; + break; + } + + pairs << sanitizeString(it.key()).toUtf8() + " : " + serializedValue; + } + + str += join(pairs, ", "); + str += " }"; + } + else if ((data.typeId() == QMetaType::Type::QVariantMap)) // variant is a map? + { + const QVariantMap vmap = data.toMap(); + QMapIterator it(vmap); + str = "{ "; + QList pairs; + while (it.hasNext()) { + it.next(); + QByteArray serializedValue = serialize(it.value()); + if (serializedValue.isNull()) { + success = false; + break; + } + pairs << sanitizeString(it.key()).toUtf8() + " : " + serializedValue; + } + str += join(pairs, ", "); + str += " }"; + } + else if ((data.typeId() == QMetaType::Type::QString) || + (data.typeId() == QMetaType::Type::QByteArray)) // a string or a byte array? + { + str = sanitizeString(data.toString()).toUtf8(); + } + else if (data.typeId() == QMetaType::Type::Double) // double? + { + str = QByteArray::number(data.toDouble(), 'g', 20); + if (!str.contains(".") && !str.contains("e")) { + str += ".0"; + } + } + else if (data.typeId() == QMetaType::Type::Bool) // boolean value? + { + str = data.toBool() ? "true" : "false"; + } + else if (data.typeId() == QMetaType::Type::ULongLong) // large unsigned number? + { + str = QByteArray::number(data.value()); + } else if (data.canConvert()) // any signed number? + { + str = QByteArray::number(data.value()); + } else if (data.canConvert()) { + str = QString::number(data.value()).toUtf8(); + } else if (data.canConvert()) // can value be converted to string? + { + // this will catch QDate, QDateTime, QUrl, ... + str = sanitizeString(data.toString()).toUtf8(); + } else { + success = false; + } + if (success) { + return str; + } else { + return QByteArray(); + } +} + +/** + * parseValue + */ +QVariant Json::parseValue(const QString &json, int &index, bool &success) +{ + // Determine what kind of data we should parse by + // checking out the upcoming token + switch (Json::lookAhead(json, index)) { + case JsonTokenString: + return Json::parseString(json, index, success); + case JsonTokenNumber: + return Json::parseNumber(json, index); + case JsonTokenCurlyOpen: + return Json::parseObject(json, index, success); + case JsonTokenSquaredOpen: + return Json::parseArray(json, index, success); + case JsonTokenTrue: + Json::nextToken(json, index); + return QVariant(true); + case JsonTokenFalse: + Json::nextToken(json, index); + return QVariant(false); + case JsonTokenNull: + Json::nextToken(json, index); + return QVariant(); + case JsonTokenNone: + break; + } + + // If there were no tokens, flag the failure and return an empty QVariant + success = false; + return QVariant(); +} + +/** + * parseObject + */ +QVariant Json::parseObject(const QString &json, int &index, bool &success) +{ + QVariantMap map; + int token; + + // Get rid of the whitespace and increment index + Json::nextToken(json, index); + + // Loop through all of the key/value pairs of the object + bool done = false; + while (!done) { + // Get the upcoming token + token = Json::lookAhead(json, index); + + if (token == JsonTokenNone) { + success = false; + return QVariantMap(); + } else if (token == JsonTokenComma) { + Json::nextToken(json, index); + } else if (token == JsonTokenCurlyClose) { + Json::nextToken(json, index); + return map; + } else { + // Parse the key/value pair's name + QString name = Json::parseString(json, index, success).toString(); + + if (!success) { + return QVariantMap(); + } + + // Get the next token + token = Json::nextToken(json, index); + + // If the next token is not a colon, flag the failure + // return an empty QVariant + if (token != JsonTokenColon) { + success = false; + return QVariant(QVariantMap()); + } + + // Parse the key/value pair's value + QVariant value = Json::parseValue(json, index, success); + + if (!success) { + return QVariantMap(); + } + + // Assign the value to the key in the map + map[name] = value; + } + } + + // Return the map successfully + return QVariant(map); +} + +/** + * parseArray + */ +QVariant Json::parseArray(const QString &json, int &index, bool &success) +{ + QVariantList list; + + Json::nextToken(json, index); + + bool done = false; + while (!done) { + int token = Json::lookAhead(json, index); + + if (token == JsonTokenNone) { + success = false; + return QVariantList(); + } else if (token == JsonTokenComma) { + Json::nextToken(json, index); + } else if (token == JsonTokenSquaredClose) { + Json::nextToken(json, index); + break; + } else { + QVariant value = Json::parseValue(json, index, success); + + if (!success) { + return QVariantList(); + } + + list.push_back(value); + } + } + + return QVariant(list); +} + +/** + * parseString + */ +QVariant Json::parseString(const QString &json, int &index, bool &success) +{ + QString s; + QChar c; + + Json::eatWhitespace(json, index); + + c = json[index++]; + + bool complete = false; + while (!complete) { + if (index == json.size()) { + break; + } + + c = json[index++]; + + if (c == '\"') { + complete = true; + break; + } else if (c == '\\') { + if (index == json.size()) { + break; + } + + c = json[index++]; + + if (c == '\"') { + s.append('\"'); + } else if (c == '\\') { + s.append('\\'); + } else if (c == '/') { + s.append('/'); + } else if (c == 'b') { + s.append('\b'); + } else if (c == 'f') { + s.append('\f'); + } else if (c == 'n') { + s.append('\n'); + } else if (c == 'r') { + s.append('\r'); + } else if (c == 't') { + s.append('\t'); + } else if (c == 'u') { + int remainingLength = json.size() - index; + + if (remainingLength >= 4) { + QString unicodeStr = json.mid(index, 4); + + int symbol = unicodeStr.toInt(0, 16); + + s.append(QChar(symbol)); + + index += 4; + } else { + break; + } + } + } else { + s.append(c); + } + } + + if (!complete) { + success = false; + return QVariant(); + } + + return QVariant(s); +} + +/** + * parseNumber + */ +QVariant Json::parseNumber(const QString &json, int &index) +{ + Json::eatWhitespace(json, index); + + int lastIndex = Json::lastIndexOfNumber(json, index); + int charLength = (lastIndex - index) + 1; + QString numberStr; + + numberStr = json.mid(index, charLength); + + index = lastIndex + 1; + + if (numberStr.contains('.')) { + return QVariant(numberStr.toDouble(NULL)); + } else if (numberStr.startsWith('-')) { + return QVariant(numberStr.toLongLong(NULL)); + } else { + return QVariant(numberStr.toULongLong(NULL)); + } +} + +/** + * lastIndexOfNumber + */ +int Json::lastIndexOfNumber(const QString &json, int index) +{ + static const QString numericCharacters("0123456789+-.eE"); + int lastIndex; + + for (lastIndex = index; lastIndex < json.size(); lastIndex++) { + if (numericCharacters.indexOf(json[lastIndex]) == -1) { + break; + } + } + + return lastIndex - 1; +} + +/** + * eatWhitespace + */ +void Json::eatWhitespace(const QString &json, int &index) +{ + static const QString whitespaceChars(" \t\n\r"); + for (; index < json.size(); index++) { + if (whitespaceChars.indexOf(json[index]) == -1) { + break; + } + } +} + +/** + * lookAhead + */ +int Json::lookAhead(const QString &json, int index) +{ + int saveIndex = index; + return Json::nextToken(json, saveIndex); +} + +/** + * nextToken + */ +int Json::nextToken(const QString &json, int &index) +{ + Json::eatWhitespace(json, index); + + if (index == json.size()) { + return JsonTokenNone; + } + + QChar c = json[index]; + index++; + switch (c.toLatin1()) { + case '{': + return JsonTokenCurlyOpen; + case '}': + return JsonTokenCurlyClose; + case '[': + return JsonTokenSquaredOpen; + case ']': + return JsonTokenSquaredClose; + case ',': + return JsonTokenComma; + case '"': + return JsonTokenString; + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case '-': + return JsonTokenNumber; + case ':': + return JsonTokenColon; + } + + index--; + + int remainingLength = json.size() - index; + + // True + if (remainingLength >= 4) { + if (json[index] == 't' && json[index + 1] == 'r' && json[index + 2] == 'u' && json[index + 3] == 'e') { + index += 4; + return JsonTokenTrue; + } + } + + // False + if (remainingLength >= 5) { + if (json[index] == 'f' && json[index + 1] == 'a' && json[index + 2] == 'l' && json[index + 3] == 's' && + json[index + 4] == 'e') { + index += 5; + return JsonTokenFalse; + } + } + + // Null + if (remainingLength >= 4) { + if (json[index] == 'n' && json[index + 1] == 'u' && json[index + 2] == 'l' && json[index + 3] == 'l') { + index += 4; + return JsonTokenNull; + } + } + + return JsonTokenNone; +} + +} // namespace QtJson diff --git a/oracle/src/qt-json/json.h b/oracle/src/qt-json/json.h new file mode 100644 index 000000000..cf0499d4e --- /dev/null +++ b/oracle/src/qt-json/json.h @@ -0,0 +1,204 @@ +/* Copyright 2011 Eeli Reilin. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation + * are those of the authors and should not be interpreted as representing + * official policies, either expressed or implied, of Eeli Reilin. + */ + +/** + * \file json.h + */ + +#ifndef JSON_H +#define JSON_H + +#include +#include + +namespace QtJson +{ + +/** + * \enum JsonToken + */ +enum JsonToken +{ + JsonTokenNone = 0, + JsonTokenCurlyOpen = 1, + JsonTokenCurlyClose = 2, + JsonTokenSquaredOpen = 3, + JsonTokenSquaredClose = 4, + JsonTokenColon = 5, + JsonTokenComma = 6, + JsonTokenString = 7, + JsonTokenNumber = 8, + JsonTokenTrue = 9, + JsonTokenFalse = 10, + JsonTokenNull = 11 +}; + +/** + * \class Json + * \brief A JSON data parser + * + * Json parses a JSON data into a QVariant hierarchy. + */ +class Json +{ + public: + /** + * Parse a JSON string + * + * \param json The JSON data + */ + static QVariant parse(const QString &json); + + /** + * Parse a JSON string + * + * \param json The JSON data + * \param success The success of the parsing + */ + static QVariant parse(const QString &json, bool &success); + + /** + * This method generates a textual JSON representation + * + * \param data The JSON data generated by the parser. + * \param success The success of the serialization + */ + static QByteArray serialize(const QVariant &data); + + /** + * This method generates a textual JSON representation + * + * \param data The JSON data generated by the parser. + * \param success The success of the serialization + * + * \return QByteArray Textual JSON representation + */ + static QByteArray serialize(const QVariant &data, bool &success); + + private: + /** + * Parses a value starting from index + * + * \param json The JSON data + * \param index The start index + * \param success The success of the parse process + * + * \return QVariant The parsed value + */ + static QVariant parseValue(const QString &json, int &index, + bool &success); + + /** + * Parses an object starting from index + * + * \param json The JSON data + * \param index The start index + * \param success The success of the object parse + * + * \return QVariant The parsed object map + */ + static QVariant parseObject(const QString &json, int &index, + bool &success); + + /** + * Parses an array starting from index + * + * \param json The JSON data + * \param index The starting index + * \param success The success of the array parse + * + * \return QVariant The parsed variant array + */ + static QVariant parseArray(const QString &json, int &index, + bool &success); + + /** + * Parses a string starting from index + * + * \param json The JSON data + * \param index The starting index + * \param success The success of the string parse + * + * \return QVariant The parsed string + */ + static QVariant parseString(const QString &json, int &index, + bool &success); + + /** + * Parses a number starting from index + * + * \param json The JSON data + * \param index The starting index + * + * \return QVariant The parsed number + */ + static QVariant parseNumber(const QString &json, int &index); + + /** + * Get the last index of a number starting from index + * + * \param json The JSON data + * \param index The starting index + * + * \return The last index of the number + */ + static int lastIndexOfNumber(const QString &json, int index); + + /** + * Skip unwanted whitespace symbols starting from index + * + * \param json The JSON data + * \param index The start index + */ + static void eatWhitespace(const QString &json, int &index); + + /** + * Check what token lies ahead + * + * \param json The JSON data + * \param index The starting index + * + * \return int The upcoming token + */ + static int lookAhead(const QString &json, int index); + + /** + * Get the next JSON token + * + * \param json The JSON data + * \param index The starting index + * + * \return int The next JSON token + */ + static int nextToken(const QString &json, int &index); +}; + + +} //end namespace + +#endif //JSON_H diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index d126390c7..c5c1e9097 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -7,35 +7,3 @@ endif() target_link_libraries(parse_cipt_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) add_test(NAME parse_cipt_test COMMAND parse_cipt_test) - -# Oracle importer unit tests -add_executable( - oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp - oracle_importer_test.cpp -) - -if(NOT GTEST_FOUND) - add_dependencies(oracle_importer_test gtest) -endif() - -target_link_libraries( - oracle_importer_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) - -add_test(NAME oracle_importer_test COMMAND oracle_importer_test) - -# Oracle importer benchmark tests (manual, not run in CI) -add_executable( - oracle_importer_benchmark_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp - ../../oracle/src/parsehelpers.cpp oracle_importer_benchmark_test.cpp -) - -if(NOT GTEST_FOUND) - add_dependencies(oracle_importer_benchmark_test gtest) -endif() - -target_link_libraries( - oracle_importer_benchmark_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} -) diff --git a/tests/oracle/oracle_importer_benchmark_test.cpp b/tests/oracle/oracle_importer_benchmark_test.cpp deleted file mode 100644 index b3cc55319..000000000 --- a/tests/oracle/oracle_importer_benchmark_test.cpp +++ /dev/null @@ -1,264 +0,0 @@ -#include "../../oracle/src/oracleimporter.h" - -#include "gtest/gtest.h" -#include -#include -#include -#include -#include -#include - -// Helper: build a synthetic MTGJSON-style JSON with the given number of sets and cards per set -static QByteArray buildSyntheticData(int numSets, int cardsPerSet) -{ - QJsonObject dataObj; - for (int s = 0; s < numSets; ++s) { - QJsonArray cardsArray; - for (int c = 0; c < cardsPerSet; ++c) { - QJsonObject card; - card["name"] = QString("Card %1").arg(s * cardsPerSet + c); - card["text"] = "This is a test card with some rules text."; - card["layout"] = "normal"; - card["manaCost"] = "{W}"; - card["type"] = "Creature — Human"; - card["power"] = "2"; - card["toughness"] = "2"; - card["colors"] = QJsonArray{"W"}; - card["colorIdentity"] = QJsonArray{"W"}; - card["types"] = QJsonArray{"Creature"}; - // Real MTGJSON types: floats and booleans, not strings. This - // exercises the QVariant coercion in the property reader. - card["convertedManaCost"] = 1.0; - card["manaValue"] = 1.0; - card["isOnlineOnly"] = false; - card["isRebalanced"] = false; - - QJsonObject legalities; - legalities["standard"] = "legal"; - legalities["modern"] = "legal"; - legalities["legacy"] = "legal"; - legalities["vintage"] = "legal"; - legalities["commander"] = "legal"; - card["legalities"] = legalities; - - QJsonObject identifiers; - identifiers["scryfallId"] = QString("id-%1-%2").arg(s).arg(c); - card["identifiers"] = identifiers; - - // In AllPrintings, number and rarity are flat fields on the card - // object, exactly as set below. - card["number"] = QString::number(c + 1); - card["rarity"] = "common"; - - cardsArray.append(card); - } - - QJsonObject setObj; - setObj["code"] = QString("T%1").arg(s, 2, 10, QChar('0')); - setObj["name"] = QString("Test Set %1").arg(s); - setObj["type"] = "expansion"; - setObj["releaseDate"] = "2024-01-01"; - setObj["cards"] = cardsArray; - - dataObj[QString("T%1").arg(s, 2, 10, QChar('0'))] = setObj; - } - - QJsonObject root; - root["data"] = dataObj; - return QJsonDocument(root).toJson(QJsonDocument::Compact); -} - -// ============================================================================ -// Import throughput benchmark -// ============================================================================ - -TEST(OracleBenchmark, ImportThroughput) -{ - static constexpr int numSets = 10; - static constexpr int cardsPerSet = 500; - - QByteArray data = buildSyntheticData(numSets, cardsPerSet); - - OracleImporter importer; - - // Phase 1: Parse JSON - QElapsedTimer timer; - timer.start(); - bool ok = importer.readSetsFromByteArray(data); - ASSERT_TRUE(ok); - qint64 parseMs = timer.elapsed(); - - // Phase 2: Import cards - timer.restart(); - int importedSets = importer.startImport(); - qint64 importMs = timer.elapsed(); - - int totalImported = 0; - for (const auto &card : importer.getCardList()) { - Q_UNUSED(card); - totalImported++; - } - - // The fixture generates globally unique card names, so the expected - // counts are exact: a regression here means cards were dropped. - ASSERT_EQ(importedSets, numSets); - ASSERT_EQ(totalImported, numSets * cardsPerSet); - // Real-data probe: numeric convertedManaCost must be coerced to text - // (regression for the QJsonValue::toString() reader in #7214). - auto probeCard = importer.getCardList().value("Card 0"); - ASSERT_FALSE(probeCard.isNull()); - ASSERT_EQ(probeCard->getProperty("cmc"), "1"); - - qDebug().noquote() - << QString("Oracle Import Benchmark: %1 sets, %2 unique cards").arg(importedSets).arg(totalImported); - qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); - qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); - qDebug().noquote() << QString(" Total: %1 ms").arg(parseMs + importMs); - if (importMs > 0) { - qDebug().noquote() << QString(" Throughput: %1 cards/sec") - .arg(static_cast(totalImported) / importMs * 1000.0, 0, 'f', 0); - } -} - -// ============================================================================ -// readSetsFromByteArray benchmark -// ============================================================================ - -TEST(OracleBenchmark, ParseJsonThroughput) -{ - static constexpr int numSets = 20; - static constexpr int cardsPerSet = 1000; - - QByteArray data = buildSyntheticData(numSets, cardsPerSet); - - // Run 5 iterations and report average - static constexpr int iterations = 5; - qint64 totalMs = 0; - - for (int i = 0; i < iterations; ++i) { - OracleImporter importer; - QElapsedTimer timer; - timer.start(); - bool ok = importer.readSetsFromByteArray(data); - ASSERT_TRUE(ok); - totalMs += timer.elapsed(); - } - - qint64 avgMs = totalMs / iterations; - qDebug().noquote() << QString("Parse Benchmark (%1 iterations): avg %2 ms for %3 sets x %4 cards") - .arg(iterations) - .arg(avgMs) - .arg(numSets) - .arg(cardsPerSet); -} - -// ============================================================================ -// Split card merging benchmark -// ============================================================================ - -TEST(OracleBenchmark, SplitCardMerging) -{ - static constexpr int numSplitCards = 1000; - - QJsonArray cardsList; - for (int i = 0; i < numSplitCards; ++i) { - QJsonObject face1; - face1["name"] = QString("Fire %1 // Ice %1").arg(i); - face1["text"] = "Fire side text."; - face1["layout"] = "split"; - face1["side"] = "a"; - face1["faceName"] = QString("Fire %1").arg(i); - face1["colors"] = QJsonArray{"R"}; - face1["colorIdentity"] = QJsonArray{"R"}; - face1["types"] = QJsonArray{"Instant"}; - face1["manaCost"] = "{R}"; - face1["legalities"] = QJsonObject{{"standard", "not_legal"}}; - face1["identifiers"] = QJsonObject{{"scryfallId", QString("f-%1").arg(i)}}; - face1["number"] = QString::number(i + 1); - face1["rarity"] = "uncommon"; - - QJsonObject face2; - face2["name"] = QString("Fire %1 // Ice %1").arg(i); - face2["text"] = "Ice side text."; - face2["layout"] = "split"; - face2["side"] = "b"; - face2["faceName"] = QString("Ice %1").arg(i); - face2["colors"] = QJsonArray{"U"}; - face2["colorIdentity"] = QJsonArray{"U"}; - face2["types"] = QJsonArray{"Instant"}; - face2["manaCost"] = "{U}"; - face2["legalities"] = QJsonObject{{"standard", "not_legal"}}; - face2["identifiers"] = QJsonObject{{"scryfallId", QString("i-%1").arg(i)}}; - face2["number"] = QString::number(i + 1); - face2["rarity"] = "uncommon"; - - cardsList.append(face1); - cardsList.append(face2); - } - - NoopCardSetPriorityController controller; - OracleImporter importer; - CardSetPtr set = CardSet::newInstance(&controller, "TST", "Split Test"); - - QElapsedTimer timer; - timer.start(); - int count = importer.importCardsFromSet(set, cardsList); - qint64 ms = timer.elapsed(); - - ASSERT_EQ(count, numSplitCards); - qDebug().noquote() << QString("Split Card Merge Benchmark: %1 cards in %2 ms (%3 cards/sec)") - .arg(count) - .arg(ms) - .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); -} - -// ============================================================================ -// sortAndReduceColors microbenchmark -// ============================================================================ - -// We can't call sortAndReduceColors directly (it's static), so we benchmark -// through importCardsFromSet with color properties. - -TEST(OracleBenchmark, ImportCardsWithColors) -{ - static constexpr int numCards = 10000; - - NoopCardSetPriorityController controller; - OracleImporter importer; - CardSetPtr set = CardSet::newInstance(&controller, "TST", "Color Test"); - - QJsonArray cardsList; - for (int i = 0; i < numCards; ++i) { - QJsonObject card; - card["name"] = QString("Color Card %1").arg(i); - card["text"] = "Rules text."; - card["layout"] = "normal"; - card["manaCost"] = "{W}"; - card["type"] = "Creature — Human"; - card["types"] = QJsonArray{"Creature"}; - card["colors"] = QJsonArray{"B", "R", "G", "W", "U"}; - card["colorIdentity"] = QJsonArray{"B", "R", "G", "W", "U"}; - card["number"] = QString::number(i + 1); - card["rarity"] = "common"; - card["legalities"] = QJsonObject{{"standard", "legal"}}; - card["identifiers"] = QJsonObject{{"scryfallId", QString("c-%1").arg(i)}}; - cardsList.append(card); - } - - QElapsedTimer timer; - timer.start(); - int count = importer.importCardsFromSet(set, cardsList); - qint64 ms = timer.elapsed(); - - ASSERT_EQ(count, numCards); - qDebug().noquote() << QString("Import with Colors Benchmark: %1 cards in %2 ms (%3 cards/sec)") - .arg(count) - .arg(ms) - .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); -} - -int main(int argc, char **argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp deleted file mode 100644 index 145a2ca0f..000000000 --- a/tests/oracle/oracle_importer_test.cpp +++ /dev/null @@ -1,552 +0,0 @@ -#include "../../oracle/src/oracleimporter.h" - -#include "gtest/gtest.h" -#include -#include -#include -#include -#include -#include -#include - -class OracleImporterTest : public ::testing::Test -{ -protected: - void SetUp() override - { - controller = new NoopCardSetPriorityController(); - importer = new OracleImporter(); - set = CardSet::newInstance(controller, "TST", "Test Set"); - } - - void TearDown() override - { - delete importer; - delete controller; - } - - // Helper: build a minimal card JSON object - QJsonObject makeCard(const QString &name, - const QString &colors = "", - const QString &colorIdentity = "", - const QVariantMap &legalities = {}) - { - QJsonObject card; - card["name"] = name; - card["text"] = "Rules text."; - card["layout"] = "normal"; - card["manaCost"] = "{W}"; - card["type"] = "Creature — Human"; - card["types"] = QJsonArray{"Creature"}; - card["number"] = "1"; - card["rarity"] = "common"; - - if (!colors.isEmpty()) { - QJsonArray arr; - for (const QChar &c : colors) { - arr.append(QString(c)); - } - card["colors"] = arr; - } - if (!colorIdentity.isEmpty()) { - QJsonArray arr; - for (const QChar &c : colorIdentity) { - arr.append(QString(c)); - } - card["colorIdentity"] = arr; - } - if (!legalities.isEmpty()) { - QJsonObject legalObj; - for (auto it = legalities.constBegin(); it != legalities.constEnd(); ++it) { - legalObj[it.key()] = it.value().toString(); - } - card["legalities"] = legalObj; - } - - QJsonObject identifiers; - identifiers["scryfallId"] = QUuid::createUuid().toString(QUuid::WithoutBraces); - card["identifiers"] = identifiers; - - return card; - } - - NoopCardSetPriorityController *controller; - OracleImporter *importer; - CardSetPtr set; -}; - -// ============================================================================ -// sortAndReduceColors tests (tested via importCardsFromSet) -// ============================================================================ - -TEST_F(OracleImporterTest, SortAndReduceColorsSingleColor) -{ - QJsonArray cards{makeCard("Red Card", "R", "R")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Red Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("colors"), "R"); -} - -TEST_F(OracleImporterTest, SortAndReduceColorsDeduplicates) -{ - QJsonArray cards{makeCard("Dedup Card", "WWUUB", "WU")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Dedup Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("colors"), "WUB"); -} - -TEST_F(OracleImporterTest, SortAndReduceColorsSortsWUBRG) -{ - QJsonArray cards{makeCard("Sort Card", "RGW", "RGW")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Sort Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("colors"), "WRG"); -} - -TEST_F(OracleImporterTest, SortAndReduceColorsAllFive) -{ - QJsonArray cards{makeCard("Five Color", "BRGWU", "BRGWU")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Five Color"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("colors"), "WUBRG"); -} - -TEST_F(OracleImporterTest, SortAndReduceColorIdentity) -{ - QJsonArray cards{makeCard("Color Id Card", "W", "GWR")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Color Id Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("coloridentity"), "WRG"); -} - -TEST_F(OracleImporterTest, SingleColorNotSorted) -{ - QJsonArray cards{makeCard("Single Card", "B", "B")}; - importer->importCardsFromSet(set, cards); - - auto card = importer->getCardList().value("Single Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("colors"), "B"); -} - -// ============================================================================ -// Legality guard tests -// ============================================================================ - -TEST_F(OracleImporterTest, NewCardKeepsLegalityProperties) -{ - // Verifies that format-* properties survive addCard on a fresh card - // (not the combineLegalities guard, which only runs on existing printings). - QVariantMap leg; - leg["standard"] = "legal"; - leg["modern"] = "legal"; - QJsonArray cards{makeCard("Legal Card", "", "", leg)}; - - importer->importCardsFromSet(set, cards); - auto card = importer->getCardList().value("Legal Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("format-standard"), "legal"); - ASSERT_EQ(card->getProperty("format-modern"), "legal"); -} - -TEST_F(OracleImporterTest, LegalityMergeAllowedWhenCardHasNoLegalities) -{ - // First printing carries no legalities at all, so the guard's - // `properties.filter(formatRegex).empty()` predicate is true and the - // second printing's legalities must be merged in. - QJsonArray cards1{makeCard("Unmerged Card")}; - importer->importCardsFromSet(set, cards1); - - CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); - QVariantMap leg; - leg["standard"] = "legal"; - QJsonArray cards2{makeCard("Unmerged Card", "", "", leg)}; - importer->importCardsFromSet(set2, cards2); - - auto card = importer->getCardList().value("Unmerged Card"); - ASSERT_FALSE(card.isNull()); - ASSERT_EQ(card->getProperty("format-standard"), "legal"); -} - -TEST_F(OracleImporterTest, LegalityGuardPreservesFirstPrinting) -{ - // First printing: standard=legal, modern=legal - QVariantMap leg1; - leg1["standard"] = "legal"; - leg1["modern"] = "legal"; - QJsonArray cards1{makeCard("Guarded Card", "", "", leg1)}; - importer->importCardsFromSet(set, cards1); - - // Second printing: standard=banned, modern=not_legal - CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); - QVariantMap leg2; - leg2["standard"] = "banned"; - leg2["modern"] = "not_legal"; - QJsonArray cards2{makeCard("Guarded Card", "", "", leg2)}; - importer->importCardsFromSet(set2, cards2); - - auto card = importer->getCardList().value("Guarded Card"); - ASSERT_FALSE(card.isNull()); - // Guard should preserve first printing's legalities - ASSERT_EQ(card->getProperty("format-standard"), "legal"); - ASSERT_EQ(card->getProperty("format-modern"), "legal"); -} - -// ============================================================================ -// createDefaultMagicFormats tests -// ============================================================================ - -TEST_F(OracleImporterTest, CreateDefaultMagicFormatsContainsExpectedFormats) -{ - auto formats = importer->createDefaultMagicFormats(); - ASSERT_TRUE(formats.contains("standard")); - ASSERT_TRUE(formats.contains("modern")); - ASSERT_TRUE(formats.contains("legacy")); - ASSERT_TRUE(formats.contains("vintage")); - ASSERT_TRUE(formats.contains("commander")); - ASSERT_TRUE(formats.contains("pauper")); - ASSERT_TRUE(formats.contains("pioneer")); - ASSERT_TRUE(formats.contains("brawl")); - ASSERT_TRUE(formats.contains("historic")); - ASSERT_TRUE(formats.contains("timeless")); - ASSERT_TRUE(formats.contains("duel")); - ASSERT_TRUE(formats.contains("oathbreaker")); -} - -TEST_F(OracleImporterTest, CreateDefaultMagicFormatsSingletonDeckSizes) -{ - auto formats = importer->createDefaultMagicFormats(); - auto commander = formats.value("commander"); - ASSERT_FALSE(commander.isNull()); - ASSERT_EQ(commander->minDeckSize, 100); - ASSERT_EQ(commander->maxDeckSize, 100); - ASSERT_EQ(commander->maxSideboardSize, 15); - - auto brawl = formats.value("brawl"); - ASSERT_FALSE(brawl.isNull()); - ASSERT_EQ(brawl->minDeckSize, 60); - ASSERT_EQ(brawl->maxDeckSize, 60); -} - -TEST_F(OracleImporterTest, CreateDefaultMagicFormatsVintageHasRestricted) -{ - auto formats = importer->createDefaultMagicFormats(); - auto vintage = formats.value("vintage"); - ASSERT_FALSE(vintage.isNull()); - bool hasRestricted = false; - for (const auto &ac : vintage->allowedCounts) { - if (ac.label == "restricted") { - hasRestricted = true; - ASSERT_EQ(ac.max, 1); - } - } - ASSERT_TRUE(hasRestricted); -} - -TEST_F(OracleImporterTest, CreateDefaultMagicFormatsRegexMatchesBasicLands) -{ - auto formats = importer->createDefaultMagicFormats(); - auto standard = formats.value("standard"); - ASSERT_FALSE(standard.isNull()); - ASSERT_FALSE(standard->exceptions.isEmpty()); - - auto &basicLandsException = standard->exceptions.first(); - ASSERT_FALSE(basicLandsException.conditions.isEmpty()); - - auto &condition = basicLandsException.conditions.first(); - ASSERT_EQ(condition.field, "type"); - ASSERT_EQ(condition.matchType, "regex"); - - // Verify the regex actually works (was broken before: \b = backspace, not word boundary) - QRegularExpression regex(condition.value); - ASSERT_TRUE(regex.isValid()); - ASSERT_TRUE(regex.match("Basic Land — Forest").hasMatch()); - ASSERT_TRUE(regex.match("Basic Snow Land — Mountain").hasMatch()); - ASSERT_FALSE(regex.match("Creature — Elf Warrior").hasMatch()); -} - -TEST_F(OracleImporterTest, CreateDefaultMagicFormatsCaching) -{ - // The memoized map returns the same FormatRulesPtr instances, so the - // shared pointers must be identical across calls. This is the only - // observable effect of the cache: contents would match either way. - auto first = importer->createDefaultMagicFormats(); - auto second = importer->createDefaultMagicFormats(); - ASSERT_EQ(first.value("standard").data(), second.value("standard").data()); -} - -// ============================================================================ -// readSetsFromByteArray tests -// ============================================================================ - -TEST_F(OracleImporterTest, ReadSetsFromByteArrayValidJson) -{ - QJsonObject setObj; - setObj["code"] = "tst"; - setObj["name"] = "Test Set"; - setObj["type"] = "expansion"; - setObj["releaseDate"] = "2024-01-01"; - setObj["cards"] = QJsonArray(); - - QJsonObject root; - root["data"] = QJsonObject{{"TST", setObj}}; - - QByteArray data = QJsonDocument(root).toJson(); - ASSERT_TRUE(importer->readSetsFromByteArray(data)); - ASSERT_EQ(importer->getSets().size(), 1); - ASSERT_EQ(importer->getSets().first().getShortName(), "TST"); -} - -TEST_F(OracleImporterTest, ReadSetsFromByteArrayInvalidJson) -{ - QByteArray data = "not valid json"; - ASSERT_FALSE(importer->readSetsFromByteArray(data)); -} - -TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmptyData) -{ - QJsonObject root; - root["data"] = QJsonObject(); - - QByteArray data = QJsonDocument(root).toJson(); - ASSERT_FALSE(importer->readSetsFromByteArray(data)); -} - -TEST_F(OracleImporterTest, ReadSetsFromByteArrayCapitalizesSetType) -{ - QJsonObject setObj; - setObj["code"] = "ftv"; - setObj["name"] = "From The Vault"; - setObj["type"] = "from_the_vault"; - setObj["releaseDate"] = "2024-01-01"; - setObj["cards"] = QJsonArray(); - - QJsonObject root; - root["data"] = QJsonObject{{"FTV", setObj}}; - - QByteArray data = QJsonDocument(root).toJson(); - ASSERT_TRUE(importer->readSetsFromByteArray(data)); - ASSERT_EQ(importer->getSets().first().getSetType(), "From the Vault"); -} - -TEST_F(OracleImporterTest, ReadSetsFromByteArraySortsSetsByName) -{ - // QJsonObject iterates keys in lexicographic order ("AAA" before "ZZZ"), - // so leaving the natural order matching the alphabetical sort makes the - // assertion pass trivially. Inverting it keeps the sort meaningful: - // iteration yields "AAA" (Zeta Set) first, then the sort by name must - // promote "ZZZ" (Alpha Set) to the front. - QJsonObject setA; - setA["code"] = "aaa"; - setA["name"] = "Zeta Set"; - setA["type"] = "expansion"; - setA["releaseDate"] = "2024-01-01"; - setA["cards"] = QJsonArray(); - - QJsonObject setB; - setB["code"] = "zzz"; - setB["name"] = "Alpha Set"; - setB["type"] = "expansion"; - setB["releaseDate"] = "2024-01-01"; - setB["cards"] = QJsonArray(); - - QJsonObject root; - root["data"] = QJsonObject{{"AAA", setA}, {"ZZZ", setB}}; - - QByteArray data = QJsonDocument(root).toJson(); - ASSERT_TRUE(importer->readSetsFromByteArray(data)); - auto sets = importer->getSets(); - ASSERT_GE(sets.size(), 2); - ASSERT_EQ(sets.first().getShortName(), "ZZZ"); -} - -// ============================================================================ -// Split card coloridentity tests -// ============================================================================ - -TEST_F(OracleImporterTest, SplitCardColorIdentityConcatenated) -{ - QJsonObject leg{{"standard", "not_legal"}}; - - QJsonObject face1; - face1["name"] = "Fire // Ice"; - face1["text"] = "Fire deals 2 damage."; - face1["layout"] = "split"; - face1["side"] = "a"; - face1["faceName"] = "Fire"; - face1["colors"] = QJsonArray{"R"}; - face1["colorIdentity"] = QJsonArray{"R"}; - face1["types"] = QJsonArray{"Instant"}; - face1["manaCost"] = "{R}"; - face1["legalities"] = leg; - face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}}; - face1["number"] = "1"; - face1["rarity"] = "uncommon"; - - QJsonObject face2; - face2["name"] = "Fire // Ice"; - face2["text"] = "Ice taps target artifact."; - face2["layout"] = "split"; - face2["side"] = "b"; - face2["faceName"] = "Ice"; - face2["colors"] = QJsonArray{"U"}; - face2["colorIdentity"] = QJsonArray{"U"}; - face2["types"] = QJsonArray{"Instant"}; - face2["manaCost"] = "{U}"; - face2["legalities"] = leg; - face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}}; - face2["number"] = "1"; - face2["rarity"] = "uncommon"; - - QJsonArray cardsList{face1, face2}; - int count = importer->importCardsFromSet(set, cardsList); - ASSERT_EQ(count, 1); - - auto card = importer->getCardList().value("Fire // Ice"); - ASSERT_FALSE(card.isNull()); - - // coloridentity should be "RU" (concatenated), then sorted to "UR" - // by sortAndReduceColors when it reaches addCard - ASSERT_EQ(card->getProperty("coloridentity"), "UR"); -} - -TEST_F(OracleImporterTest, SplitCardColorsConcatenated) -{ - QJsonObject leg{{"standard", "not_legal"}}; - - QJsonObject face1; - face1["name"] = "Fire // Ice"; - face1["text"] = "Fire deals 2 damage."; - face1["layout"] = "split"; - face1["side"] = "a"; - face1["faceName"] = "Fire"; - face1["colors"] = QJsonArray{"R"}; - face1["colorIdentity"] = QJsonArray{"R"}; - face1["types"] = QJsonArray{"Instant"}; - face1["manaCost"] = "{R}"; - face1["legalities"] = leg; - face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}}; - face1["number"] = "1"; - face1["rarity"] = "uncommon"; - - QJsonObject face2; - face2["name"] = "Fire // Ice"; - face2["text"] = "Ice taps target artifact."; - face2["layout"] = "split"; - face2["side"] = "b"; - face2["faceName"] = "Ice"; - face2["colors"] = QJsonArray{"U"}; - face2["colorIdentity"] = QJsonArray{"U"}; - face2["types"] = QJsonArray{"Instant"}; - face2["manaCost"] = "{U}"; - face2["legalities"] = leg; - face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}}; - face2["number"] = "1"; - face2["rarity"] = "uncommon"; - - QJsonArray cardsList{face1, face2}; - importer->importCardsFromSet(set, cardsList); - - auto card = importer->getCardList().value("Fire // Ice"); - ASSERT_FALSE(card.isNull()); - - QString colors = card->getProperty("colors"); - ASSERT_FALSE(colors.contains("//")) << "colors should not contain '//', got: " << colors.toStdString(); - ASSERT_TRUE(colors.contains("R")); - ASSERT_TRUE(colors.contains("U")); -} - -// ============================================================================ -// Mana cost formatting tests -// ============================================================================ - -TEST_F(OracleImporterTest, ManaCostStripsBraces) -{ - QJsonObject card = makeCard("Mana Card"); - card["manaCost"] = "{2}{W}{B}"; - QJsonArray cards{card}; - - importer->importCardsFromSet(set, cards); - auto result = importer->getCardList().value("Mana Card"); - ASSERT_FALSE(result.isNull()); - ASSERT_EQ(result->getProperty("manacost"), "2WB"); -} - -// cmc comes through as a JSON number ("convertedManaCost"/"manaValue" are -// floats in AllPrintings), so this pins the number-to-text coercion that -// QJsonValue::toString() dropped in #7214. -TEST_F(OracleImporterTest, NumericManaValueCoercedToCmc) -{ - QJsonObject card = makeCard("Cmc Card"); - card["manaValue"] = 3; - QJsonArray cards{card}; - - importer->importCardsFromSet(set, cards); - auto result = importer->getCardList().value("Cmc Card"); - ASSERT_FALSE(result.isNull()); - ASSERT_EQ(result->getProperty("cmc"), "3"); -} - -TEST_F(OracleImporterTest, LegacyConvertedManaCostCoercedToCmc) -{ - QJsonObject card = makeCard("Legacy Cmc Card"); - card["convertedManaCost"] = 3.0; - QJsonArray cards{card}; - - importer->importCardsFromSet(set, cards); - auto result = importer->getCardList().value("Legacy Cmc Card"); - ASSERT_FALSE(result.isNull()); - ASSERT_EQ(result->getProperty("cmc"), "3"); -} - -// ============================================================================ -// Card deduplication tests -// ============================================================================ - -TEST_F(OracleImporterTest, DuplicateCardNameReturnsExisting) -{ - QJsonArray cards{makeCard("Dupe Card")}; - importer->importCardsFromSet(set, cards); - - CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); - QJsonArray cards2{makeCard("Dupe Card")}; - importer->importCardsFromSet(set2, cards2); - - ASSERT_EQ(importer->getCardList().size(), 1); -} - -TEST_F(OracleImporterTest, AELigatureReplaced) -{ - QJsonObject card = makeCard(QString::fromUtf8("\xC3\x86ther Vial")); // Æther Vial - QJsonArray cards{card}; - - importer->importCardsFromSet(set, cards); - // Æ is replaced with AE, resulting in "AEther Vial" - ASSERT_FALSE(importer->getCardList().contains(QString::fromUtf8("\xC3\x86ther Vial"))); - ASSERT_TRUE(importer->getCardList().contains("AEther Vial")); -} - -TEST_F(OracleImporterTest, ApostropheNormalized) -{ - QJsonObject card = makeCard(QString::fromUtf8("Jace\u2019s Ingenuity")); - QJsonArray cards{card}; - - importer->importCardsFromSet(set, cards); - ASSERT_TRUE(importer->getCardList().contains("Jace's Ingenuity")); -} - -int main(int argc, char **argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -}