Cockatrice/tests/oracle/oracle_importer_test.cpp
BruebachL 6138fbd0a0
[Card] Add Scryfall Tagger tag search and filtering (#7337)
* [Card] Import Scryfall Tagger tags from MTGJson

* [Card] Search cards by Scryfall Tagger tag

* [Card] Add a Tag attribute to the card filter tree

* [Card] Add a Tags filter to the visual card database

* [Oracle] Add a script to inject Scryfall tags into AllPrintings

* [Card] Elide long tag lists in the card info and drop the Scryfall label from the tag filter tooltip

* [Oracle] Load card tags from a tag database during import

Add a TagDatabase loader that understands Scryfall's Oracle Tags bulk data and user-supplied tag files (tag objects, flat oracle-id or name maps, optional manifest, JSON or JSONL, gzip/xz/zip). The importer joins the slugs on identifiers.scryfallOracleId while the identifiers are still available, falls back to the card name, and unions them with any tags the source already provides. Split-card faces merge through the existing tag union path.

This replaces oracle/scripts/inject_scryfall_tags.py, which patched AllPrintings.json before import. Loading during import keeps tags on by default for every source and lets users pair Oracle with any tag database.

* [Oracle] Add a tag data step to the import wizard

Insert a Tags page between set selection and the import. It downloads Scryfall's Oracle Tags by default (or a user-supplied file/URL), can include parent tags, and persists tagsenabled, tagssource and tagsparents in oracle.ini. The page loads asynchronously and soft-fails in background mode, so a tag download or parse error never aborts the card database import.

* [Help] Describe tag search without naming Scryfall

Tags can come from Scryfall's Oracle Tags or any user-supplied tag database, so the search help should not claim a single source.

* [Oracle] Fix Scryfall tag requests and the retry loop

api.scryfall.com rejects requests that lack an Accept header with HTTP 400, and QNetworkAccessManager does not add one. Send application/json for the bulk metadata request and */* for the tag file, and surface the URL plus Scryfall's JSON error body when a request fails.

A failed tag load used to call next(), which re-ran validatePage() and started the same failing download again, trapping the user in an error loop. Stay on the page instead, drop the unusable database, and tell the user they can retry, change the source, or untick the option to import without tags. Also explain parent tags inline with a real Scryfall hierarchy example.

* [Oracle] Give the parent-tag explainer room to breathe

The wrapped explainer sat flush under the checkbox with no separation from the status label, which made the paragraph look cramped. Indent it under the checkbox and add a little vertical margin.

* [Oracle] Stop the parent-tag explainer from being clipped

A word-wrapped QLabel reports a one-line sizeHint, so the wizard page reserved too little height and the explanation lost its top and bottom lines. Pin the vertical size policy to Minimum on the explainer and on the status label, so the wrapped height becomes part of the page minimum. Verified with a layout probe: the label went from 28 px tall (needed 56) to its full height.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-08 20:57:45 +01:00

1506 lines
57 KiB
C++

#include "../../oracle/src/database_writer.h"
#include "../../oracle/src/oracleimporter.h"
#include "../../oracle/src/raw_json_scanner.h"
#include "../../oracle/src/tag_database.h"
#include "../support/scoped_file_size_limit.h"
#include "libcockatrice/card/card_info.h"
#include "gtest/gtest.h"
#include <QByteArray>
#include <QChar>
#include <QDate>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QJsonValueRef>
#include <QList>
#include <QMap>
#include <QObject>
#include <QPair>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QSharedPointer>
#include <QString>
#include <QTemporaryDir>
#include <QUuid>
#include <QVariant>
#include <QVariantMap>
#include <QtGlobal>
#include <libcockatrice/card/format/format_legality_rules.h>
#include <libcockatrice/card/set/card_set.h>
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
#include <string>
#include <utility>
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;
}
// Helper: build a single MTGJSON foreignData entry
QJsonObject makeForeignEntry(const QString &language, const QString &name, const QString &text)
{
QJsonObject entry;
entry["language"] = language;
entry["name"] = name;
if (!text.isEmpty()) {
entry["text"] = text;
}
return entry;
}
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");
}
// ============================================================================
// Scryfall Tagger tag tests
// ============================================================================
TEST_F(OracleImporterTest, ImportsScryfallTags)
{
QJsonObject card = makeCard("Ramp Card");
card["tags"] = QJsonArray{"ramp", "removal"};
QJsonArray cards{card};
importer->importCardsFromSet(set, cards);
auto result = importer->getCardList().value("Ramp Card");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getProperty("tags"), "ramp removal");
}
TEST_F(OracleImporterTest, TagsAreNormalizedDedupedAndSorted)
{
QJsonObject card = makeCard("Messy Tags");
card["tags"] = QJsonArray{"Ramp", " removal ", "ramp", ""};
QJsonArray cards{card};
importer->importCardsFromSet(set, cards);
auto result = importer->getCardList().value("Messy Tags");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getProperty("tags"), "ramp removal");
}
TEST_F(OracleImporterTest, CardsWithoutTagsHaveNoTagsProperty)
{
QJsonArray cards{makeCard("Untagged Card")};
importer->importCardsFromSet(set, cards);
auto result = importer->getCardList().value("Untagged Card");
ASSERT_FALSE(result.isNull());
ASSERT_FALSE(result->hasProperty("tags"));
}
TEST_F(OracleImporterTest, SplitCardTagsAreUnioned)
{
QJsonObject face1 = makeCard("Fire // Ice");
face1["layout"] = "split";
face1["side"] = "a";
face1["faceName"] = "Fire";
face1["tags"] = QJsonArray{"removal"};
QJsonObject face2 = makeCard("Fire // Ice");
face2["layout"] = "split";
face2["side"] = "b";
face2["faceName"] = "Ice";
face2["tags"] = QJsonArray{"card-advantage", "removal"};
QJsonArray cards{face1, face2};
importer->importCardsFromSet(set, cards);
auto result = importer->getCardList().value("Fire // Ice");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getProperty("tags"), "card-advantage removal");
}
// ============================================================================
// Tag database join tests
// ============================================================================
TEST_F(OracleImporterTest, AppliesTagDatabaseByOracleId)
{
QJsonObject card = makeCard("Tagged By Id");
QJsonObject identifiers = card["identifiers"].toObject();
identifiers["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
card["identifiers"] = identifiers;
QString error;
const auto database = TagDatabase::loadFromData(
R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal", "ramp"]})json", TagDatabase::LoadOptions{}, &error);
ASSERT_TRUE(database) << error.toStdString();
importer->setTagDatabase(database);
importer->importCardsFromSet(set, QJsonArray{card});
auto result = importer->getCardList().value("Tagged By Id");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getProperty("tags"), "ramp removal");
}
TEST_F(OracleImporterTest, AppliesTagDatabaseByNameWhenOracleIdIsMissing)
{
QJsonObject card = makeCard("Lightning Bolt");
QString error;
const auto database = TagDatabase::loadFromData(R"json({"Lightning Bolt": ["burn", "removal"]})json",
TagDatabase::LoadOptions{}, &error);
ASSERT_TRUE(database) << error.toStdString();
importer->setTagDatabase(database);
importer->importCardsFromSet(set, QJsonArray{card});
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getProperty("tags"), "burn removal");
}
TEST_F(OracleImporterTest, TagDatabaseUnionsWithSourceTags)
{
QJsonObject card = makeCard("Union Card");
QJsonObject identifiers = card["identifiers"].toObject();
identifiers["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
card["identifiers"] = identifiers;
card["tags"] = QJsonArray{"ramp"};
QString error;
const auto database = TagDatabase::loadFromData(
R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal", "ramp"]})json", TagDatabase::LoadOptions{}, &error);
ASSERT_TRUE(database) << error.toStdString();
importer->setTagDatabase(database);
importer->importCardsFromSet(set, QJsonArray{card});
auto result = importer->getCardList().value("Union Card");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getProperty("tags"), "ramp removal");
}
TEST_F(OracleImporterTest, TagDatabaseSplitFacesUnionTheirTags)
{
QJsonObject face1 = makeCard("Fire // Ice");
face1["layout"] = "split";
face1["side"] = "a";
face1["faceName"] = "Fire";
QJsonObject identifiers1 = face1["identifiers"].toObject();
identifiers1["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
face1["identifiers"] = identifiers1;
QJsonObject face2 = makeCard("Fire // Ice");
face2["layout"] = "split";
face2["side"] = "b";
face2["faceName"] = "Ice";
QJsonObject identifiers2 = face2["identifiers"].toObject();
identifiers2["scryfallOracleId"] = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
face2["identifiers"] = identifiers2;
QString error;
const auto database = TagDatabase::loadFromData(
R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal"], "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb": ["card-advantage"]})json",
TagDatabase::LoadOptions{}, &error);
ASSERT_TRUE(database) << error.toStdString();
importer->setTagDatabase(database);
importer->importCardsFromSet(set, QJsonArray{face1, face2});
auto result = importer->getCardList().value("Fire // Ice");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getProperty("tags"), "card-advantage removal");
}
// ============================================================================
// 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"));
}
// ============================================================================
// RawJson scanner tests
// ============================================================================
TEST_F(OracleImporterTest, ScanSetRangesMatchFullJsonParse)
{
QJsonObject root;
QJsonObject data;
data["AAA"] = makeCard("Alpha Card");
data["BBB"] = makeCard("Beta Card");
root["data"] = data;
const QByteArray bytes = QJsonDocument(root).toJson(QJsonDocument::Compact);
RawJson::ScanError error;
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(bytes, &error);
ASSERT_FALSE(error.isError()) << error.message.toStdString();
ASSERT_EQ(ranges.size(), 2);
const QJsonObject wholeData = QJsonDocument::fromJson(bytes).object().value("data").toObject();
for (const RawJson::SetRange &range : ranges) {
QJsonParseError parseError;
const QJsonDocument sliceDoc = QJsonDocument::fromJson(
QByteArray(bytes.constData() + range.dataRange.start, range.dataRange.length), &parseError);
ASSERT_EQ(parseError.error, QJsonParseError::NoError)
<< range.code.toStdString() << ": " << parseError.errorString().toStdString();
ASSERT_EQ(sliceDoc.object(), wholeData.value(range.code).toObject()) << "set " << range.code.toStdString();
}
}
TEST_F(OracleImporterTest, ScanSetRangesDecodesEscapesAndCountsCards)
{
const QByteArray json = "{\"data\":{\"KEY\":{\"code\":\"zzz\",\"name\":\"\\u00c9tude \\ud83d\\ude00\","
"\"type\":\"expansion\",\"releaseDate\":\"2024-01-05\","
"\"cards\":[{\"name\":\"a\"},{\"name\":\"b\"},{\"name\":\"c\"}]}}}";
RawJson::ScanError error;
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(json, &error);
ASSERT_FALSE(error.isError());
ASSERT_EQ(ranges.size(), 1);
const RawJson::SetRange &range = ranges.first();
ASSERT_EQ(range.code, "zzz"); // inner "code" wins over the object key
const QString expectedName = QString::fromUtf8("\xC3\x89tude ") + QChar(0xD83D) + QChar(0xDE00);
ASSERT_EQ(range.name, expectedName);
ASSERT_EQ(range.type, "expansion");
ASSERT_EQ(range.releaseDate, "2024-01-05");
ASSERT_EQ(range.dataRange.cardCount, 3);
QJsonParseError parseError;
const QJsonDocument sliceDoc = QJsonDocument::fromJson(
QByteArray(json.constData() + range.dataRange.start, range.dataRange.length), &parseError);
ASSERT_EQ(parseError.error, QJsonParseError::NoError);
ASSERT_EQ(sliceDoc.object().value("name").toString(), expectedName);
ASSERT_EQ(sliceDoc.object().value("cards").toArray().size(), 3);
}
TEST_F(OracleImporterTest, ScanSetRangesRejectsInvalidJson)
{
const QList<QByteArray> invalid = {"not json",
"[]",
"{\"data\":[]}",
"{\"data\":{}}",
"{\"other\":{}}",
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\","
"\"releaseDate\":\"2024-01-01\",\"cards\":[]}}} trailing",
"{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\uZZZZ\"}]}}}",
"{\"data\":{\"A\":{\"cards\":[{\"name\":\"bad \\q escape\"}]}}}",
"{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\ud800\"}]}}}"};
for (const QByteArray &json : invalid) {
RawJson::ScanError error;
RawJson::scanSetRanges(json, &error);
EXPECT_TRUE(error.isError()) << "expected failure for: " << json.constData();
}
}
TEST_F(OracleImporterTest, ScanSetRangesMatchesFullJsonParseVerdicts)
{
// Verdicts must agree with QJsonDocument::fromJson for the inputs below —
// including the metadata quirks ("name": null, "type": 7, "releaseDate": null,
// "cards": null) that used to make the scanner reject sets Qt accepts.
const QList<QByteArray> inputs = {
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[{"
"\"n\":1}]}}}",
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":null,\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}",
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":null,\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}",
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":7,\"releaseDate\":\"2024-01-01\",\"cards\":null}}}",
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"releaseDate\":\"2024-01-01\",\"cards\":[1,2,3]}}}",
// unescaped control character inside a string: QJsonDocument and
// skipString both accept it, so the scanner must not reject the whole doc
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"N\tX\",\"releaseDate\":\"2024-01-01\",\"cards\":[{\"n\":1}]}}}",
// structurally invalid JSON (both parsers must reject)
"not json",
"{\"data\":{\"A\":{\"name\":\"unterminated}}",
};
for (const QByteArray &input : inputs) {
QJsonParseError qtError;
QJsonDocument::fromJson(input, &qtError);
const bool qtOk = qtError.error == QJsonParseError::NoError;
RawJson::ScanError scanError;
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(input, &scanError);
EXPECT_EQ(qtOk, !scanError.isError()) << "verdict mismatch for: " << input.constData();
if (scanError.isError()) {
continue;
}
for (const RawJson::SetRange &range : ranges) {
QJsonParseError sliceError;
QJsonDocument::fromJson(QByteArray(input.constData() + range.dataRange.start, range.dataRange.length),
&sliceError);
EXPECT_EQ(sliceError.error, QJsonParseError::NoError) << "bad range slice for: " << input.constData();
}
}
}
TEST_F(OracleImporterTest, ScanSetRangesRejectsDeepNesting)
{
// Far beyond the shared 1024 container cap: Qt reports DeepNesting and the
// scanner must reject too, without overflowing the stack through its
// recursive skipValue walk.
QString nesting;
nesting.reserve(10000);
for (int i = 0; i < 5000; ++i) {
nesting += '[';
}
for (int i = 0; i < 5000; ++i) {
nesting += ']';
}
const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8();
QJsonParseError qtError;
QJsonDocument::fromJson(json, &qtError);
ASSERT_NE(qtError.error, QJsonParseError::NoError) << "expected Qt to reject deep nesting";
RawJson::ScanError scanError;
RawJson::scanSetRanges(json, &scanError);
ASSERT_TRUE(scanError.isError()) << "scanner accepted a document Qt rejects as too deeply nested";
}
TEST_F(OracleImporterTest, ScanSetRangesAcceptsQtMaxNesting)
{
// Pins the boundary rather than only the far-past case: a depth Qt still
// accepts must be accepted by the scanner too. Before the fix the scanner's
// cap was roughly half of Qt's (each level cost two decrements), so a
// depth of 1000 here was rejected even though QJsonDocument parses it.
constexpr int depth = 1000;
QString nesting;
nesting.reserve(2 * depth);
for (int i = 0; i < depth; ++i) {
nesting += '[';
}
for (int i = 0; i < depth; ++i) {
nesting += ']';
}
const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8();
QJsonParseError qtError;
QJsonDocument::fromJson(json, &qtError);
ASSERT_EQ(qtError.error, QJsonParseError::NoError) << "expected Qt to accept depth " << depth;
RawJson::ScanError scanError;
RawJson::scanSetRanges(json, &scanError);
ASSERT_FALSE(scanError.isError()) << "scanner rejected a document Qt accepts at depth " << depth;
}
// ============================================================================
// Lazy per-set parsing tests
// ============================================================================
TEST_F(OracleImporterTest, StartImportParsesSetsLazily)
{
QJsonObject setObj = makeCard("Lazy Import Card");
QJsonArray cards;
cards.append(setObj);
QJsonObject dataSet;
dataSet["code"] = "tst";
dataSet["name"] = "Test Set";
dataSet["type"] = "expansion";
dataSet["releaseDate"] = "2024-01-01";
dataSet["cards"] = cards;
QJsonObject root;
root["data"] = QJsonObject{{"TST", dataSet}};
const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact);
ASSERT_TRUE(importer->readSetsFromByteArray(data));
ASSERT_FALSE(importer->getRawSetsData().isEmpty());
const int importedSets = importer->startImport();
ASSERT_EQ(importedSets, 1);
ASSERT_EQ(importer->getCardList().size(), 1);
ASSERT_FALSE(importer->getCardList().value("Lazy Import Card").isNull());
}
// ============================================================================
// Scan progress reporting tests
// ============================================================================
TEST(OracleScanProgress, ScanProgressReportsMonotonicBytesToTotal)
{
QJsonObject setObj;
setObj["code"] = "tst";
setObj["name"] = "Test Set";
setObj["type"] = "expansion";
setObj["releaseDate"] = "2024-01-01";
QJsonArray cards;
for (int i = 0; i < 40; ++i) {
QJsonObject card;
card["name"] = QString("Card %1").arg(i);
card["text"] = "Some rules text used to bulk up the card payload.";
card["layout"] = "normal";
cards.append(card);
}
setObj["cards"] = cards;
QJsonObject root;
root["data"] = QJsonObject{{"TST", setObj}};
const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact);
QList<QPair<qsizetype, qsizetype>> reports;
RawJson::ScanError error;
const QList<RawJson::SetRange> ranges =
RawJson::scanSetRanges(data, &error, [&reports](qsizetype bytesRead, qsizetype totalBytes) {
reports.append({bytesRead, totalBytes});
});
ASSERT_FALSE(error.isError()) << error.message.toStdString();
ASSERT_EQ(ranges.size(), 1);
ASSERT_FALSE(reports.isEmpty());
ASSERT_GT(reports.size(), 1);
qsizetype last = 0;
for (const auto &[bytesRead, totalBytes] : reports) {
ASSERT_EQ(totalBytes, data.size());
ASSERT_GE(bytesRead, last) << "scan progress must be monotonic";
ASSERT_LE(bytesRead, totalBytes) << "scan progress must not overshoot the document size";
last = bytesRead;
}
ASSERT_EQ(reports.constLast().first, data.size()) << "scan must end at 100%";
ASSERT_LE(reports.size(), 160) << "scan reports must be throttled";
}
TEST(OracleScanProgress, ScanWithoutCallbackStillParses)
{
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}};
const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact);
RawJson::ScanError error;
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(data, &error);
ASSERT_FALSE(error.isError()) << error.message.toStdString();
ASSERT_EQ(ranges.size(), 1);
ASSERT_EQ(ranges.first().code, "tst");
}
TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmitsScanProgress)
{
QJsonObject setObj;
setObj["code"] = "tst";
setObj["name"] = "Test Set";
setObj["type"] = "expansion";
setObj["releaseDate"] = "2024-01-01";
QJsonArray cards;
for (int i = 0; i < 40; ++i) {
QJsonObject card;
card["name"] = QString("Card %1").arg(i);
cards.append(card);
}
setObj["cards"] = cards;
QJsonObject root;
root["data"] = QJsonObject{{"TST", setObj}};
const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact);
QList<QPair<qsizetype, qsizetype>> emissions;
QObject::connect(importer, &OracleImporter::dataReadProgress,
[&emissions](int bytesRead, int totalBytes) { emissions.append({bytesRead, totalBytes}); });
ASSERT_TRUE(importer->readSetsFromByteArray(data));
ASSERT_FALSE(emissions.isEmpty());
for (const auto &[bytesRead, totalBytes] : emissions) {
ASSERT_EQ(totalBytes, data.size());
ASSERT_GE(bytesRead, 0);
ASSERT_LE(bytesRead, totalBytes);
}
ASSERT_EQ(emissions.constLast().first, data.size());
}
TEST_F(OracleImporterTest, DisablingProgressReportingSuppressesScanEmissions)
{
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}};
const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact);
int emissions = 0;
QObject::connect(importer, &OracleImporter::dataReadProgress, [&emissions](int, int) { ++emissions; });
importer->setProgressReporting(false);
ASSERT_TRUE(importer->readSetsFromByteArray(data));
ASSERT_EQ(emissions, 0);
importer->setProgressReporting(true);
ASSERT_TRUE(importer->readSetsFromByteArray(data));
ASSERT_GT(emissions, 0);
}
// Localized card text tests
// ============================================================================
TEST_F(OracleImporterTest, ImportsLocalizedTextForRequestedLanguage)
{
QJsonObject card = makeCard("Lightning Bolt");
card["foreignData"] =
QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")};
QJsonArray cards{card};
importer->setCardLang("de");
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag");
ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu.");
// English identity untouched
ASSERT_EQ(result->getName(), "Lightning Bolt");
ASSERT_EQ(result->getText(), "Rules text.");
}
TEST_F(OracleImporterTest, ImportsLocalizedNameAndTextForMultiFaceCards)
{
// MTGJSON reports multi-face cards (adventure/split/aftermath/prepare) as one
// card object per face; every face carries the joined name but only its own
// face's rules text in foreignData. The importer joins the per-face texts with
// the same separator as the English merge.
QJsonObject front = makeCard("Disruptive Stormbrood // Petty Revenge");
front["layout"] = "adventure";
front["faceName"] = "Disruptive Stormbrood";
front["side"] = "a";
front["foreignData"] = QJsonArray{
makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache",
"Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine "
"Verzauberung deiner Wahl.")};
QJsonObject back = makeCard("Disruptive Stormbrood // Petty Revenge");
back["layout"] = "adventure";
back["faceName"] = "Petty Revenge";
back["side"] = "b";
back["text"] = "Destroy target creature.";
back["foreignData"] = QJsonArray{makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache",
"Zerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger.")};
QJsonArray cards{front, back};
importer->setCardLang("de");
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Disruptive Stormbrood // Petty Revenge");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getLocalizedName("de"), "Disruptive Stormbrood // Kleinliche Rache");
ASSERT_EQ(result->getLocalizedText("de"),
"Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine Verzauberung "
"deiner Wahl.\n\n---\n\nZerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger.");
// English identity untouched
ASSERT_EQ(result->getName(), "Disruptive Stormbrood // Petty Revenge");
ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nDestroy target creature.");
}
TEST_F(OracleImporterTest, MultiFaceCardsWithoutCompleteForeignTextKeepEnglishText)
{
// Both faces must carry a foreignData text for the joined text; otherwise the
// rules text stays English while the localized name (from a later complete
// printing) is still applied.
QJsonObject front = makeCard("Wear // Tear");
front["layout"] = "split";
front["faceName"] = "Wear";
front["side"] = "a";
front["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Verschleiß-Text.")};
QJsonObject back = makeCard("Wear // Tear");
back["layout"] = "split";
back["faceName"] = "Tear";
back["side"] = "b";
back["text"] = "Tear rules text.";
back["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "")};
QJsonArray cards{front, back};
importer->setCardLang("de");
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Wear // Tear");
ASSERT_FALSE(result.isNull());
// The joined name is still applied.
ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung");
// The incomplete text must not become the card's localized text.
ASSERT_TRUE(result->getLocalizedTexts().isEmpty());
ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nTear rules text.");
}
TEST_F(OracleImporterTest, DefaultLanguageSkipsForeignData)
{
QJsonObject card = makeCard("Lightning Bolt");
card["foreignData"] =
QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")};
QJsonArray cards{card};
// cardLang defaults to "en" — foreignData must never be imported
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_TRUE(result->getLocalizedNames().isEmpty());
ASSERT_TRUE(result->getLocalizedTexts().isEmpty());
}
TEST_F(OracleImporterTest, UnsupportedLanguageSkipsForeignData)
{
QJsonObject card = makeCard("Lightning Bolt");
card["foreignData"] = QJsonArray{makeForeignEntry("xx", "Kochanie", "Grzmot uderza.")};
QJsonArray cards{card};
importer->setCardLang("xx");
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_TRUE(result->getLocalizedNames().isEmpty());
}
TEST_F(OracleImporterTest, NonMatchingLanguageNotCollected)
{
QJsonObject card = makeCard("Lightning Bolt");
card["foreignData"] = QJsonArray{makeForeignEntry("French", "Éclair", "L'Éclair inflige 3 blessures.")};
QJsonArray cards{card};
importer->setCardLang("de");
importer->importCardsFromSet(set, cards);
importer->applyLocalizedData();
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_TRUE(result->getLocalizedNames().isEmpty());
}
TEST_F(OracleImporterTest, HigherPrioritySetWinsForReprint)
{
// First printing in a reprint set, then another in a (more authoritative)
// core set: the core set's German text must win even though it was seen later.
QJsonObject reprintCard = makeCard("Lightning Bolt");
reprintCard["foreignData"] = QJsonArray{makeForeignEntry("German", "Blitzschlag", "Älterer deutscher Text.")};
CardSetPtr reprintSet =
CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint);
importer->setCardLang("de");
importer->importCardsFromSet(reprintSet, QJsonArray{reprintCard});
QJsonObject primaryCard = makeCard("Lightning Bolt");
primaryCard["foreignData"] =
QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")};
CardSetPtr primarySet =
CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary);
importer->importCardsFromSet(primarySet, QJsonArray{primaryCard});
importer->applyLocalizedData();
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag");
ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu.");
ASSERT_EQ(importer->getCardList().size(), 1);
}
TEST_F(OracleImporterTest, HigherPrioritySplitSetWinsForReprint)
{
// Split cards print each face as its own card object; the joined text is
// collected per set with the same priority policy as single-face cards, so a
// reprint set's German text must yield to the core set's even when reprints
// are imported first.
QJsonObject reprintFront = makeCard("Wear // Tear");
reprintFront["layout"] = "split";
reprintFront["faceName"] = "Wear";
reprintFront["side"] = "a";
reprintFront["foreignData"] =
QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear alter Text.")};
QJsonObject reprintBack = makeCard("Wear // Tear");
reprintBack["layout"] = "split";
reprintBack["faceName"] = "Tear";
reprintBack["side"] = "b";
reprintBack["foreignData"] =
QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear alter Text.")};
CardSetPtr reprintSet =
CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint);
importer->setCardLang("de");
importer->importCardsFromSet(reprintSet, QJsonArray{reprintFront, reprintBack});
QJsonObject primaryFront = makeCard("Wear // Tear");
primaryFront["layout"] = "split";
primaryFront["faceName"] = "Wear";
primaryFront["side"] = "a";
primaryFront["foreignData"] =
QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear neuer Text.")};
QJsonObject primaryBack = makeCard("Wear // Tear");
primaryBack["layout"] = "split";
primaryBack["faceName"] = "Tear";
primaryBack["side"] = "b";
primaryBack["foreignData"] =
QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear neuer Text.")};
CardSetPtr primarySet =
CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary);
importer->importCardsFromSet(primarySet, QJsonArray{primaryFront, primaryBack});
importer->applyLocalizedData();
auto result = importer->getCardList().value("Wear // Tear");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung");
ASSERT_EQ(result->getLocalizedText("de"), "Wear neuer Text.\n\n---\n\nTear neuer Text.");
ASSERT_EQ(importer->getCardList().size(), 1);
}
TEST_F(OracleImporterTest, StartImportAppliesLocalizedData)
{
QJsonObject card = makeCard("Lightning Bolt");
card["foreignData"] =
QJsonArray{makeForeignEntry("Portuguese (Brazil)", "Raio", "Raio causa 3 de dano a qualquer alvo.")};
QJsonObject dataSet;
dataSet["code"] = "tst";
dataSet["name"] = "Test Set";
dataSet["type"] = "expansion";
dataSet["releaseDate"] = "2024-01-01";
dataSet["cards"] = QJsonArray{card};
QJsonObject root;
root["data"] = QJsonObject{{"TST", dataSet}};
importer->setCardLang("pt");
ASSERT_TRUE(importer->readSetsFromByteArray(QJsonDocument(root).toJson(QJsonDocument::Compact)));
ASSERT_EQ(importer->startImport(), 1);
auto result = importer->getCardList().value("Lightning Bolt");
ASSERT_FALSE(result.isNull());
ASSERT_EQ(result->getLocalizedName("pt"), "Raio");
ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo.");
}
namespace
{
QByteArray refreshSource(const QString &name = "New Card")
{
QJsonObject card{{"name", name}, {"text", "Rules text."}, {"layout", "normal"},
{"type", "Artifact"}, {"number", "1"}, {"rarity", "common"}};
QJsonObject set{{"code", "TST"}, {"name", "Test Set"}, {"type", "expansion"}, {"cards", QJsonArray{card}}};
return QJsonDocument(QJsonObject{{"data", QJsonObject{{"TST", set}}}}).toJson(QJsonDocument::Compact);
}
} // namespace
TEST_F(OracleImporterTest, FailedLazyParseKeepsInstalledDatabase)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
QFile original(path);
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
ASSERT_EQ(original.write("existing database"), 17);
original.close();
// The metadata scanner skips numeric values without converting them. Qt's
// per-set parser rejects an overflowing number after selection succeeds.
auto badSet = QJsonDocument::fromJson(refreshSource()).object().value("data").toObject().value("TST").toObject();
auto goodSet = badSet;
goodSet["code"] = "AAA";
goodSet["name"] = "A successful first set";
auto badCard = badSet.value("cards").toArray().first().toObject();
badCard["number"] = "overflow";
badSet["cards"] = QJsonArray{badCard};
QByteArray data = QJsonDocument(QJsonObject{{"data", QJsonObject{{"AAA", goodSet}, {"TST", badSet}}}})
.toJson(QJsonDocument::Compact);
data.replace("\"number\":\"overflow\"", "\"number\":1e9999");
ASSERT_TRUE(importer->readSetsFromByteArray(data));
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_TRUE(importer->getImportError().contains("TST"));
EXPECT_TRUE(importer->getCardList().isEmpty());
EXPECT_FALSE(importer->saveToFile(path, {}, {}));
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
EXPECT_EQ(original.readAll(), "existing database");
}
TEST_F(OracleImporterTest, EmptyImportCannotReplaceInstalledCards)
{
const QByteArray source = R"({"data":{"TST":{"code":"TST","name":"Test","type":"expansion","cards":[]}}})";
ASSERT_TRUE(importer->readSetsFromByteArray(source));
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_FALSE(importer->getImportError().isEmpty());
}
TEST_F(OracleImporterTest, MissingSourceBytesCannotProduceSuccessfulImport)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
const auto selected = importer->getSets();
importer->releaseSetData();
importer->getSets() = selected;
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_TRUE(importer->getImportError().contains("TST"));
}
TEST_F(OracleImporterTest, CancellationDuringFinalSetCannotSavePartialDatabase)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
QObject::connect(
importer, &OracleImporter::setIndexChanged, importer,
[this](int, int, const QString &name) {
if (!name.isEmpty()) {
importer->cancelImport();
}
},
Qt::DirectConnection);
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_TRUE(importer->getCardList().isEmpty());
}
TEST_F(OracleImporterTest, CancellationBeforeWorkerStartsIsNotLost)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
importer->prepareImport();
importer->cancelImport();
EXPECT_EQ(importer->startImport(), 0);
EXPECT_FALSE(importer->hasSuccessfulImport());
importer->prepareImport();
EXPECT_EQ(importer->startImport(), 1);
EXPECT_TRUE(importer->hasSuccessfulImport());
}
TEST_F(OracleImporterTest, NewImportReplacesPreviousInMemoryCards)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("Old Card")));
ASSERT_EQ(importer->startImport(), 1);
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("New Card")));
EXPECT_FALSE(importer->hasSuccessfulImport());
ASSERT_EQ(importer->startImport(), 1);
EXPECT_EQ(importer->getCardList().size(), 1);
EXPECT_FALSE(importer->getCardList().contains("Old Card"));
EXPECT_TRUE(importer->getCardList().contains("New Card"));
}
TEST_F(OracleImporterTest, FailedSourceLoadCannotReusePreviousSuccessfulImport)
{
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
ASSERT_EQ(importer->startImport(), 1);
ASSERT_FALSE(importer->readSetsFromByteArray("invalid JSON"));
EXPECT_FALSE(importer->hasSuccessfulImport());
EXPECT_EQ(importer->startImport(), 0);
}
TEST_F(OracleImporterTest, SuccessfulRefreshCommitsCompleteXml)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
ASSERT_EQ(importer->startImport(), 1);
importer->releaseSetData();
ASSERT_TRUE(importer->saveToFile(path, "test-source", "v1"));
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::ReadOnly));
const QByteArray xml = file.readAll();
EXPECT_TRUE(xml.contains("New Card"));
EXPECT_TRUE(xml.contains("test-source"));
EXPECT_TRUE(xml.contains("</cockatrice_carddatabase>"));
}
TEST(OracleDatabaseWriter, AcceptsCompleteSupportedVersions)
{
EXPECT_TRUE(OracleDatabase::validateXml(
"<?xml version=\"1.0\"?><cockatrice_carddatabase version=\"3\"><cards/></cockatrice_carddatabase>")
.isEmpty());
EXPECT_TRUE(OracleDatabase::validateXml("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>")
.isEmpty());
EXPECT_FALSE(
OracleDatabase::validateXml("<cockatrice_carddatabase version=\"5\"><cards/></cockatrice_carddatabase>")
.isEmpty());
}
TEST(OracleDatabaseWriter, InvalidDownloadPreservesInstalledBytes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("tokens.xml");
QFile original(path);
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
ASSERT_EQ(original.write("installed tokens"), 16);
original.close();
for (const QByteArray &payload : {QByteArray("<html>server error</html>"),
QByteArray("<cockatrice_carddatabase version=\"4\"><cards>"), QByteArray()}) {
QString error;
EXPECT_FALSE(OracleDatabase::saveXml(path, payload, &error));
EXPECT_FALSE(error.isEmpty());
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
EXPECT_EQ(original.readAll(), "installed tokens");
original.close();
}
}
TEST(OracleDatabaseWriter, CompleteDownloadReplacesInstalledBytes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
const QByteArray data("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>");
QString error = "previous failure";
ASSERT_TRUE(OracleDatabase::saveXml(path, data, &error));
EXPECT_TRUE(error.isEmpty());
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::ReadOnly));
EXPECT_EQ(file.readAll(), data);
}
#ifdef Q_OS_UNIX
TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath("cards.xml");
QFile original(path);
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
ASSERT_EQ(original.write("installed database"), 18);
original.close();
const QByteArray data = "<cockatrice_carddatabase version=\"4\"><!--" + QByteArray(16384, 'x') +
"--><cards/></cockatrice_carddatabase>";
// A real filesystem write failure: the soft file-size cap makes the writer's own
// flush fail without filling a disk. The cap is scoped rather than forked, because a
// death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS.
test_support::ScopedFileSizeLimit fileSizeCap(128);
ASSERT_TRUE(fileSizeCap.isValid());
QString error;
EXPECT_FALSE(OracleDatabase::saveXml(path, data, &error));
EXPECT_FALSE(error.isEmpty());
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
EXPECT_EQ(original.readAll(), "installed database");
}
#endif
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}