diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 587463572..06d5e97bf 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -24,6 +24,7 @@ set(oracle_SOURCES src/pagetemplates.cpp src/parsehelpers.cpp src/raw_json_scanner.cpp + src/tag_database.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/scripts/inject_scryfall_tags.py b/oracle/scripts/inject_scryfall_tags.py deleted file mode 100755 index 14a2d9c2a..000000000 --- a/oracle/scripts/inject_scryfall_tags.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -"""Inject Scryfall Tagger tags into an MTGJson AllPrintings file. - -MTGJson does not ship Scryfall Tagger tags yet. Until it does, this script -fakes the future `tags` card property so Cockatrice's tag search and tag -filter can be exercised end to end: - - 1. It downloads (or reuses) Scryfall's official "Oracle Tags" bulk data. - 2. It builds an `oracle_id -> [tag slug, ...]` map from the taggings. - 3. It walks every card of an AllPrintings file, matches it by - `identifiers.scryfallOracleId`, and adds a `tags` array of slugs. - -The output is a regular MTGJson AllPrintings file that Oracle can import -through its "local file" option. The `tags` array it writes is exactly what -`oracleimporter.cpp` reads once MTGJson starts emitting it, so nothing about -the importer has to change later. - -Usage: - # Patch a downloaded AllPrintings.json.xz into AllPrintings.tagged.json - ./inject_scryfall_tags.py AllPrintings.json.xz AllPrintings.tagged.json - - # Reuse an already downloaded tags file and write compressed output - ./inject_scryfall_tags.py AllPrintings.json --oracle-tags oracle-tags.jsonl.gz \ - -o AllPrintings.tagged.json.gz - -This is a development stop-gap: it holds the whole input in memory, so expect -a few GB of RAM for a full AllPrintings file. -""" - -import argparse -import bz2 -import gzip -import json -import lzma -import sys -import urllib.request -import zipfile -from pathlib import Path - -USER_AGENT = "CockatriceTagMock/0.1 (+https://github.com/Cockatrice/Cockatrice)" -DEFAULT_API_URL = "https://api.scryfall.com/bulk-data/oracle-tags" -CACHE_FILE = "oracle-tags.jsonl.gz" - - -def fetch_json(url): - request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}) - with urllib.request.urlopen(request) as response: - return json.load(response) - - -def download(url, destination): - request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) - with urllib.request.urlopen(request) as response, open(destination, "wb") as handle: - while chunk := response.read(1 << 20): - handle.write(chunk) - - -def open_compressed(path, mode): - """Open a JSON/JSONL file, transparently decompressing by suffix.""" - path = Path(path) - suffix = path.suffix.lower() - if suffix == ".gz": - return gzip.open(path, mode) - if suffix == ".bz2": - return bz2.open(path, mode) - if suffix == ".xz": - return lzma.open(path, mode) - if suffix == ".zip": - archive = zipfile.ZipFile(path) - name = archive.namelist()[0] - return archive.open(name, "r" if "r" in mode else "w") - return open(path, mode) - - -def load_or_download_tags(args): - if args.oracle_tags: - return Path(args.oracle_tags) - - cache = Path(args.cache_dir) / CACHE_FILE - if cache.exists() and not args.force_download: - print(f"Using cached Scryfall Oracle Tags: {cache}", file=sys.stderr) - return cache - - print(f"Fetching {args.api_url}", file=sys.stderr) - metadata = fetch_json(args.api_url) - download_uri = metadata.get("jsonl_download_uri") or metadata.get("download_uri") - if not download_uri: - raise SystemExit("Scryfall bulk data did not provide a download URI.") - - print(f"Downloading {download_uri}", file=sys.stderr) - cache.parent.mkdir(parents=True, exist_ok=True) - download(download_uri, cache) - return cache - - -def build_oracle_id_to_tags(tags_path): - """Return {oracle_id: sorted list of tag slugs} from Scryfall tag objects. - - Scryfall serves the tags as gzipped JSONL (one tag object per line); plain - JSON arrays are accepted too so a hand-saved file keeps working. - """ - mapping = {} - with open_compressed(tags_path, "rt") as handle: - head = handle.read(1) - handle.seek(0) - if head == "[": - for tag in json.load(handle): - add_tag_object(mapping, tag) - else: - for line in handle: - line = line.strip() - if line: - add_tag_object(mapping, json.loads(line)) - - return {oracle_id: sorted(slugs) for oracle_id, slugs in mapping.items()} - - -def add_tag_object(mapping, tag): - slug = tag.get("slug") - if not slug: - return - for tagging in tag.get("taggings") or []: - oracle_id = tagging.get("oracle_id") - if oracle_id: - mapping.setdefault(oracle_id, set()).add(slug) - - -def inject_tags(all_printings, oracle_id_to_tags): - sets = all_printings.get("data", {}) - tagged_cards = 0 - total_cards = 0 - - for card_set in sets.values(): - for card in card_set.get("cards", []): - total_cards += 1 - oracle_id = (card.get("identifiers") or {}).get("scryfallOracleId") - slugs = oracle_id_to_tags.get(oracle_id) if oracle_id else None - if not slugs: - continue - - existing = set(card.get("tags") or []) - existing.update(slugs) - card["tags"] = sorted(existing) - tagged_cards += 1 - - return total_cards, tagged_cards - - -def write_json(path, payload): - with open_compressed(path, "wt") as handle: - json.dump(payload, handle, separators=(",", ":")) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("input", help="MTGJson AllPrintings file (.json/.gz/.xz/.zip/.bz2)") - parser.add_argument("-o", "--output", help="Output file; defaults to .tagged.json") - parser.add_argument("--oracle-tags", help="Pre-downloaded Scryfall Oracle Tags file (JSONL/JSON, optionally gz)") - parser.add_argument("--cache-dir", default=".", help="Where to cache the downloaded tags file") - parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Scryfall bulk-data endpoint for oracle tags") - parser.add_argument("--force-download", action="store_true", help="Ignore a cached tags file and re-download") - args = parser.parse_args() - - output = args.output or f"{args.input}.tagged.json" - - tags_path = load_or_download_tags(args) - oracle_id_to_tags = build_oracle_id_to_tags(tags_path) - print(f"Loaded tags for {len(oracle_id_to_tags)} oracle ids", file=sys.stderr) - - print(f"Reading {args.input}", file=sys.stderr) - with open_compressed(args.input, "rt") as handle: - all_printings = json.load(handle) - - total_cards, tagged_cards = inject_tags(all_printings, oracle_id_to_tags) - print(f"Tagged {tagged_cards}/{total_cards} cards", file=sys.stderr) - - print(f"Writing {output}", file=sys.stderr) - write_json(output, all_printings) - print("Done.", file=sys.stderr) - - -if __name__ == "__main__": - main() diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index a99f18631..7a9bfdf27 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -6,6 +6,7 @@ #include "libcockatrice/card/relation/card_relation_type.h" #include "libcockatrice/interfaces/noop_card_set_priority_controller.h" #include "parsehelpers.h" +#include "tag_database.h" #include #include @@ -517,9 +518,11 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson properties.insert("coloridentity", colorIdentity); } - // Scryfall Tagger tags (resolved by MTGJSON into the `tags` array). - // Stored space-separated so the `tags:` search can match each slug as a - // discrete token; slugs never contain whitespace. + // Card tags: the source's own `tags` array is used when present (MTGJson + // may ship one eventually), then the configured tag database is joined in + // while the card's identifiers are still available. Stored space-separated + // so `tags:` can match each slug as a discrete token; slugs never contain + // whitespace. QStringList tags; for (const QJsonValue &tag : card.value("tags").toArray()) { const QString tagSlug = tag.toString().trimmed().toLower(); @@ -527,6 +530,14 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson tags.append(tagSlug); } } + if (tagDatabase) { + const QString oracleId = getJsonString(identifiers, "scryfallOracleId"); + for (const QString &tagSlug : tagDatabase->tagsForCard(oracleId, name)) { + if (!tags.contains(tagSlug)) { + tags.append(tagSlug); + } + } + } if (!tags.isEmpty()) { tags.sort(); properties.insert("tags", tags.join(" ")); diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index d872e184e..187b2dfd6 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -17,12 +17,14 @@ #include #include #include +#include #include #include #include class CardRelation; class QRegularExpression; +class TagDatabase; // many users prefer not to see these sets with non english arts // they will given priority PriorityLowest @@ -210,6 +212,12 @@ private: */ bool localizationEnabled = false; + /** + * Optional tag lookup applied to every imported card. Owned by the wizard + * so it outlives the import worker; null when tags are disabled. + */ + std::shared_ptr tagDatabase; + /** * Localized name/text collected per imported card key while parsing sets, * applied to the CardInfo objects by applyLocalizedData() once all @@ -264,6 +272,16 @@ public: { progressReporting = enabled; } + /** + * @brief Supplies the tag database applied to every imported card. + * + * Pass nullptr to import without tags. The wizard keeps the database alive + * for the whole import, so the worker sees a stable pointer. + */ + void setTagDatabase(std::shared_ptr database) + { + tagDatabase = std::move(database); + } /** * Selects the ISO-639 language code whose foreignData is imported. * English (the default) and unsupported codes disable localization. diff --git a/oracle/src/tag_database.cpp b/oracle/src/tag_database.cpp new file mode 100644 index 000000000..b9c57a969 --- /dev/null +++ b/oracle/src/tag_database.cpp @@ -0,0 +1,643 @@ +#include "tag_database.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef HAS_LZMA +#include "lzma/decompress.h" +#endif + +#ifdef HAS_ZLIB +#include "zip/unzip.h" + +#include +#endif + +#include + +namespace +{ +using TagSets = QHash>; + +struct ParsedTag +{ + QString slug; + QStringList parentIds; + QList oracleIds; +}; + +struct ParseContext +{ + bool includeParentTags = false; + QHash tagsById; + TagSets oracleTags; + TagSets nameTags; +}; + +const QByteArray &gzipMagic() +{ + static const QByteArray magic = QByteArray::fromHex("1f8b"); + return magic; +} + +const QByteArray &xzMagic() +{ + static const QByteArray magic = QByteArray::fromHex("fd377a585a00"); + return magic; +} + +const QByteArray &zipMagic() +{ + static const QByteArray magic = QByteArray::fromHex("504b0304"); + return magic; +} + +QString normalizedSlug(const QString &slug) +{ + return slug.trimmed().toLower(); +} + +/** + * @brief Rejects slugs that cannot survive the space-separated `tags` property. + */ +bool isValidSlug(const QString &slug) +{ + if (slug.isEmpty()) { + return false; + } + for (const QChar &character : slug) { + if (character.isSpace()) { + return false; + } + } + return true; +} + +QString normalizedName(const QString &name) +{ + return name.trimmed().toLower(); +} + +/** + * @brief True when a map key has the shape of a Scryfall oracle id. + * + * A manifest with an explicit `key` removes the ambiguity for card names that + * happen to look like UUIDs. + */ +bool looksLikeOracleId(const QString &key) +{ + return !QUuid::fromString(key.trimmed()).isNull(); +} + +void addTags(TagSets &target, const QString &key, const QStringList &slugs) +{ + // Both key spaces are case-insensitive: Scryfall UUIDs are hex, and card + // names must match regardless of the source's capitalization. + const QString normalizedKey = key.trimmed().toLower(); + if (normalizedKey.isEmpty()) { + return; + } + + QSet &tags = target[normalizedKey]; + for (const QString &rawSlug : slugs) { + const QString slug = normalizedSlug(rawSlug); + if (isValidSlug(slug)) { + tags.insert(slug); + } + } + if (tags.isEmpty()) { + target.remove(normalizedKey); + } +} + +#ifdef HAS_ZLIB +bool decompressGzip(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + z_stream stream{}; + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Gzip support could not be initialized."); + } + return false; + } + + stream.next_in = reinterpret_cast(const_cast(input.constData())); + stream.avail_in = static_cast(input.size()); + + int result = Z_OK; + char buffer[64 * 1024]; + while (result == Z_OK) { + stream.next_out = reinterpret_cast(buffer); + stream.avail_out = sizeof(buffer); + result = inflate(&stream, Z_NO_FLUSH); + + const uInt produced = static_cast(sizeof(buffer)) - stream.avail_out; + if (produced > 0) { + output.append(buffer, static_cast(produced)); + } + + if (result != Z_OK && result != Z_STREAM_END) { + inflateEnd(&stream); + if (errorMessage) { + *errorMessage = TagDatabase::tr("Gzip decompression failed."); + } + return false; + } + if (produced == 0 && stream.avail_in == 0 && result == Z_OK) { + inflateEnd(&stream); + if (errorMessage) { + *errorMessage = TagDatabase::tr("The gzip stream is truncated."); + } + return false; + } + } + + inflateEnd(&stream); + return result == Z_STREAM_END; +} +#endif + +#ifdef HAS_LZMA +bool decompressXz(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + QByteArray inputCopy = input; + QBuffer inputBuffer(&inputCopy); + QBuffer outputBuffer(&output); + inputBuffer.open(QBuffer::ReadOnly); + outputBuffer.open(QBuffer::WriteOnly); + + XzDecompressor decompressor; + if (!decompressor.decompress(&inputBuffer, &outputBuffer)) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Xz decompression failed."); + } + return false; + } + return true; +} +#endif + +#ifdef HAS_ZLIB +bool decompressZip(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + QByteArray inputCopy = input; + QBuffer inputBuffer(&inputCopy); + UnZip archive; + const UnZip::ErrorCode openError = archive.openArchive(&inputBuffer); + if (openError != UnZip::Ok) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Failed to open the Zip archive: %1.").arg(archive.formatError(openError)); + } + return false; + } + + const QStringList files = archive.fileList(); + if (files.size() != 1) { + archive.closeArchive(); + if (errorMessage) { + *errorMessage = TagDatabase::tr("The Zip archive must contain exactly one tag database file."); + } + return false; + } + + QBuffer outputBuffer(&output); + outputBuffer.open(QBuffer::ReadWrite); + const UnZip::ErrorCode extractError = archive.extractFile(files.first(), &outputBuffer); + archive.closeArchive(); + if (extractError != UnZip::Ok) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Zip extraction failed: %1.").arg(archive.formatError(extractError)); + } + return false; + } + return true; +} +#endif + +/** + * @brief Unwraps gzip/xz/zip payloads, including nested archives. + */ +bool decompressData(QByteArray &data, QString *errorMessage) +{ + // Nested compression is legitimate (a zip around a gzip), but a handful of + // layers is enough; the cap keeps a hostile file from looping forever. + for (int layer = 0; layer < 4; ++layer) { + if (data.startsWith(gzipMagic())) { +#ifdef HAS_ZLIB + QByteArray decompressed; + if (!decompressGzip(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support gzip compressed files."); + } + return false; +#endif + } + + if (data.startsWith(xzMagic())) { +#ifdef HAS_LZMA + QByteArray decompressed; + if (!decompressXz(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support xz compressed files."); + } + return false; +#endif + } + + if (data.startsWith(zipMagic())) { +#ifdef HAS_ZLIB + QByteArray decompressed; + if (!decompressZip(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support zipped files."); + } + return false; +#endif + } + + return true; + } + + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database is compressed in too many layers."); + } + return false; +} + +bool parseKeyedMap(const QJsonObject &map, const QString &explicitKey, ParseContext &context, QString *errorMessage) +{ + if (map.isEmpty()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + if (!it.value().isArray()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag list for \"%1\" must be an array of slugs.").arg(it.key()); + } + return false; + } + + QStringList slugs; + for (const QJsonValue &value : it.value().toArray()) { + if (!value.isString()) { + if (errorMessage) { + *errorMessage = + TagDatabase::tr("The tag list for \"%1\" must contain only slug strings.").arg(it.key()); + } + return false; + } + slugs.append(value.toString()); + } + + const bool oracleKey = explicitKey.isEmpty() ? looksLikeOracleId(it.key()) + : (explicitKey == "oracle_id" || explicitKey == "oracleid"); + if (oracleKey) { + addTags(context.oracleTags, it.key().trimmed(), slugs); + } else { + addTags(context.nameTags, normalizedName(it.key()), slugs); + } + } + return true; +} + +bool parseTagRow(const QJsonObject &object, ParseContext &context) +{ + QStringList slugs; + for (const QJsonValue &value : object.value("tags").toArray()) { + if (value.isString()) { + slugs.append(value.toString()); + } + } + + const QString oracleId = object.value("oracle_id").toString().trimmed(); + if (!oracleId.isEmpty()) { + addTags(context.oracleTags, oracleId, slugs); + } + + const QString name = object.value("name").toString().trimmed(); + if (!name.isEmpty()) { + addTags(context.nameTags, normalizedName(name), slugs); + } + return true; +} + +bool parseTagObject(const QJsonObject &object, ParseContext &context) +{ + // Art and illustration tags describe a printing's artwork, not the oracle + // card, so they never belong in the card-level tag property. + const QString type = object.value("type").toString().trimmed().toLower(); + if (!type.isEmpty() && type != "oracle") { + return true; + } + + const QString slug = normalizedSlug(object.value("slug").toString()); + if (!isValidSlug(slug)) { + return true; + } + + QList oracleIds; + for (const QJsonValue &value : object.value("taggings").toArray()) { + const QString oracleId = value.toObject().value("oracle_id").toString().trimmed(); + if (!oracleId.isEmpty() && !oracleIds.contains(oracleId)) { + oracleIds.append(oracleId); + } + } + + const QString id = object.value("id").toString().trimmed(); + if (!context.includeParentTags || id.isEmpty()) { + for (const QString &oracleId : oracleIds) { + addTags(context.oracleTags, oracleId, QStringList{slug}); + } + return true; + } + + ParsedTag &tag = context.tagsById[id]; + if (tag.slug.isEmpty()) { + tag.slug = slug; + } + for (const QJsonValue &parent : object.value("parent_ids").toArray()) { + const QString parentId = parent.toString().trimmed(); + if (!parentId.isEmpty() && !tag.parentIds.contains(parentId)) { + tag.parentIds.append(parentId); + } + } + for (const QString &oracleId : oracleIds) { + if (!tag.oracleIds.contains(oracleId)) { + tag.oracleIds.append(oracleId); + } + } + return true; +} + +bool handleObject(const QJsonObject &object, ParseContext &context, QString *errorMessage) +{ + if (object.contains("slug")) { + return parseTagObject(object, context); + } + + const QJsonValue tagsValue = object.value("tags"); + if (tagsValue.isArray() && (object.contains("oracle_id") || object.contains("name"))) { + return parseTagRow(object, context); + } + if (tagsValue.isObject()) { + const QString key = object.value("key").toString().trimmed().toLower(); + return parseKeyedMap(tagsValue.toObject(), key, context, errorMessage); + } + + bool allArrays = !object.isEmpty(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + if (!it.value().isArray()) { + allArrays = false; + break; + } + } + if (allArrays) { + return parseKeyedMap(object, QString(), context, errorMessage); + } + + if (errorMessage) { + *errorMessage = TagDatabase::tr("Unrecognized tag database format."); + } + return false; +} + +bool parseJsonArray(const QJsonArray &array, ParseContext &context, QString *errorMessage) +{ + if (array.isEmpty()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + + for (const QJsonValue &value : array) { + if (!value.isObject()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database array must contain only objects."); + } + return false; + } + if (!handleObject(value.toObject(), context, errorMessage)) { + return false; + } + } + return true; +} + +bool parseJsonLines(const QByteArray &payload, ParseContext &context, QString *errorMessage) +{ + const QList lines = payload.split('\n'); + bool parsedAny = false; + + for (int index = 0; index < lines.size(); ++index) { + const QByteArray line = lines.at(index).trimmed(); + if (line.isEmpty()) { + continue; + } + + QJsonParseError lineError{}; + const QJsonDocument lineDocument = QJsonDocument::fromJson(line, &lineError); + if (lineError.error != QJsonParseError::NoError || !lineDocument.isObject()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Malformed tag database at line %1: %2") + .arg(index + 1) + .arg(lineError.errorString()); + } + return false; + } + if (!handleObject(lineDocument.object(), context, errorMessage)) { + return false; + } + parsedAny = true; + } + + if (!parsedAny) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + return true; +} + +QHash sortedLists(const TagSets &sets) +{ + QHash result; + result.reserve(sets.size()); + for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) { + if (it.value().isEmpty()) { + continue; + } + QStringList slugs = it.value().values(); + slugs.sort(); + result.insert(it.key(), slugs); + } + return result; +} + +} // namespace + +std::shared_ptr +TagDatabase::loadFromData(const QByteArray &data, const LoadOptions &options, QString *errorMessage) +{ + if (errorMessage) { + errorMessage->clear(); + } + + if (data.trimmed().isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database is empty."); + } + return nullptr; + } + + QByteArray payload = data; + if (!decompressData(payload, errorMessage)) { + return nullptr; + } + if (payload.trimmed().isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database is empty."); + } + return nullptr; + } + + ParseContext context; + context.includeParentTags = options.includeParentTags; + + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error == QJsonParseError::NoError) { + if (document.isArray()) { + if (!parseJsonArray(document.array(), context, errorMessage)) { + return nullptr; + } + } else if (document.isObject()) { + if (!handleObject(document.object(), context, errorMessage)) { + return nullptr; + } + } else { + if (errorMessage) { + *errorMessage = tr("The tag database is not a JSON object or array."); + } + return nullptr; + } + } else if (payload.trimmed().startsWith('[')) { + // A broken array is not JSONL; report the parser error instead of + // blaming an arbitrary line. + if (errorMessage) { + *errorMessage = tr("Malformed tag database: %1").arg(parseError.errorString()); + } + return nullptr; + } else if (!parseJsonLines(payload, context, errorMessage)) { + return nullptr; + } + + if (context.includeParentTags) { + // Expand upward only after every tag is known: a parent object may + // appear after its children in the file. + for (auto it = context.tagsById.constBegin(); it != context.tagsById.constEnd(); ++it) { + QStringList slugs{it->slug}; + QSet visited; + QList pending = it->parentIds; + + while (!pending.isEmpty()) { + const QString parentId = pending.takeLast(); + if (visited.contains(parentId)) { + continue; + } + visited.insert(parentId); + + const auto parent = context.tagsById.constFind(parentId); + if (parent == context.tagsById.constEnd()) { + continue; + } + slugs.append(parent->slug); + pending.append(parent->parentIds); + } + + for (const QString &oracleId : it->oracleIds) { + addTags(context.oracleTags, oracleId, slugs); + } + } + } + + auto database = std::make_shared(); + database->tagsByOracleId = sortedLists(context.oracleTags); + database->tagsByName = sortedLists(context.nameTags); + + QSet distinctSlugs; + for (const QStringList &slugs : database->tagsByOracleId) { + for (const QString &slug : slugs) { + distinctSlugs.insert(slug); + } + } + for (const QStringList &slugs : database->tagsByName) { + for (const QString &slug : slugs) { + distinctSlugs.insert(slug); + } + } + database->distinctTagCount = distinctSlugs.size(); + + if (database->isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database contains no usable tags."); + } + return nullptr; + } + return database; +} + +QStringList TagDatabase::tagsForCard(const QString &oracleId, const QString &name) const +{ + QStringList result; + if (!oracleId.isEmpty()) { + result = tagsByOracleId.value(oracleId.trimmed().toLower()); + } + + const QString normalizedCardName = normalizedName(name); + if (!normalizedCardName.isEmpty()) { + const QStringList byName = tagsByName.value(normalizedCardName); + for (const QString &slug : byName) { + if (!result.contains(slug)) { + result.append(slug); + } + } + } + + if (!result.isEmpty()) { + result.sort(); + } + return result; +} + +bool TagDatabase::isEmpty() const +{ + return tagsByOracleId.isEmpty() && tagsByName.isEmpty(); +} diff --git a/oracle/src/tag_database.h b/oracle/src/tag_database.h new file mode 100644 index 000000000..5874000f7 --- /dev/null +++ b/oracle/src/tag_database.h @@ -0,0 +1,69 @@ +#ifndef TAG_DATABASE_H +#define TAG_DATABASE_H + +#include +#include +#include +#include +#include +#include + +/** + * @brief Tag slugs keyed by Scryfall oracle id and/or card name. + * + * Loaded from Scryfall's Oracle Tags bulk data or from a user-supplied tag + * database. The data is import-time only: Oracle applies the slugs to the + * `tags` card property before writing cards.xml, so the client needs no + * tag-specific code. + * + * Accepted inputs, auto-detected: + * - Scryfall tag objects, either a JSON array or one object per JSONL line; + * - a flat map of `oracle_id` (or card name) to an array of slugs; + * - a manifest object `{"key": "oracle_id"|"name", "tags": { ... }}`; + * - JSONL rows `{"oracle_id": "...", "tags": [...]}` or the `name` equivalent. + * + * Gzip, xz and zip inputs are decompressed transparently when the build has + * the matching library. Scryfall serves its bulk data as gzipped JSONL. + */ +class TagDatabase +{ + Q_DECLARE_TR_FUNCTIONS(TagDatabase) +public: + struct LoadOptions + { + bool includeParentTags = false; + }; + + /** + * @brief Parses and decompresses a tag database. + * @return The database, or nullptr with @p errorMessage set on failure. + */ + static std::shared_ptr + loadFromData(const QByteArray &data, const LoadOptions &options, QString *errorMessage = nullptr); + + /** + * @brief Returns the sorted slugs for one card. + * + * Oracle ids are the primary key. A name-keyed database is consulted as a + * fallback, case-insensitively. An empty list means the card has no tags. + */ + [[nodiscard]] QStringList tagsForCard(const QString &oracleId, const QString &name) const; + [[nodiscard]] bool isEmpty() const; + /** @brief Number of distinct slugs in the database. */ + [[nodiscard]] int tagCount() const + { + return distinctTagCount; + } + /** @brief Number of cards that received at least one tag. */ + [[nodiscard]] int taggedCardCount() const + { + return tagsByOracleId.size() + tagsByName.size(); + } + +private: + QHash tagsByOracleId; + QHash tagsByName; ///< keys are lowercased + int distinctTagCount = 0; +}; + +#endif // TAG_DATABASE_H diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 6958673f7..32827629c 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -1,3 +1,35 @@ +# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the tag database +# loader can decompress whatever format the tag source serves, and the benchmark can +# download and decompress whatever AllPrintings format the default URL selects. +# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA +# reach every test target below regardless of where they are declared. +set(_ORACLE_COMPRESSION_SOURCES) +set(_ORACLE_COMPRESSION_LIBRARIES) + +find_package(ZLIB) +if(ZLIB_FOUND) + add_definitions("-DHAS_ZLIB") + list(APPEND _ORACLE_COMPRESSION_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) + cockatrice_disable_sanitizers_for(${_ORACLE_COMPRESSION_SOURCES}) + cockatrice_disable_coverage_for(${_ORACLE_COMPRESSION_SOURCES}) + list(APPEND _ORACLE_COMPRESSION_LIBRARIES ${ZLIB_LIBRARIES}) + include_directories(${ZLIB_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: zlib not found; gzip/zip tag databases and the zip download benchmark disabled") +endif() + +find_package(LibLZMA) +if(LIBLZMA_FOUND) + add_definitions("-DHAS_LZMA") + list(APPEND _ORACLE_COMPRESSION_SOURCES ../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp) + list(APPEND _ORACLE_COMPRESSION_LIBRARIES ${LIBLZMA_LIBRARIES}) + include_directories(${LIBLZMA_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: LibLZMA not found; xz tag databases and the xz download benchmark disabled") +endif() + cockatrice_add_test(NAME parse_cipt_test SOURCES ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp) # Oracle importer unit tests @@ -9,42 +41,29 @@ cockatrice_add_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/tag_database.cpp oracle_importer_test.cpp + ${_ORACLE_COMPRESSION_SOURCES} LIBS libcockatrice_card libcockatrice_interfaces + EXTRA_LIBS + ${_ORACLE_COMPRESSION_LIBRARIES} +) + +# Tag database format detection, parsing and parent expansion +cockatrice_add_test( + NAME + tag_database_test + SOURCES + ../../oracle/src/tag_database.cpp + tag_database_test.cpp + ${_ORACLE_COMPRESSION_SOURCES} + EXTRA_LIBS + ${_ORACLE_COMPRESSION_LIBRARIES} ) # Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark) -# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark -# can download and decompress whatever AllPrintings format the default URL selects. -# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA also -# reach the two unit tests above. That is harmless: those macros gate code only in -# oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, which no test target compiles. -find_package(ZLIB) -if(ZLIB_FOUND) - add_definitions("-DHAS_ZLIB") - set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) - cockatrice_disable_sanitizers_for(${_ORACLE_BENCH_EXTRA_SOURCES}) - cockatrice_disable_coverage_for(${_ORACLE_BENCH_EXTRA_SOURCES}) - set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) - include_directories(${ZLIB_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled") -endif() - -find_package(LibLZMA) -if(LIBLZMA_FOUND) - add_definitions("-DHAS_LZMA") - list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) - cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) - cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp) - list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) - include_directories(${LIBLZMA_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled") -endif() - cockatrice_add_manual_test( NAME oracle_importer_benchmark_test @@ -53,12 +72,13 @@ cockatrice_add_manual_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/tag_database.cpp oracle_importer_benchmark_test.cpp - ${_ORACLE_BENCH_EXTRA_SOURCES} + ${_ORACLE_COMPRESSION_SOURCES} LIBS libcockatrice_card libcockatrice_interfaces EXTRA_LIBS - ${_ORACLE_BENCH_EXTRA_LIBRARIES} + ${_ORACLE_COMPRESSION_LIBRARIES} MANUAL ) diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 2908ff415..5e40b31b1 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,5 +1,6 @@ #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" +#include "../../oracle/src/tag_database.h" #include "libcockatrice/card/card_info.h" #include "gtest/gtest.h" @@ -600,6 +601,100 @@ TEST_F(OracleImporterTest, SplitCardTagsAreUnioned) 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 // ============================================================================ diff --git a/tests/oracle/tag_database_test.cpp b/tests/oracle/tag_database_test.cpp new file mode 100644 index 000000000..92b4013a2 --- /dev/null +++ b/tests/oracle/tag_database_test.cpp @@ -0,0 +1,254 @@ +#include "../../oracle/src/tag_database.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include + +#ifdef HAS_ZLIB +#include +#endif + +namespace +{ +const QString ORACLE_A = QStringLiteral("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); +const QString ORACLE_B = QStringLiteral("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + +std::shared_ptr load(const QByteArray &data, + const TagDatabase::LoadOptions &options = TagDatabase::LoadOptions{}, + QString *errorMessage = nullptr) +{ + return TagDatabase::loadFromData(data, options, errorMessage); +} + +QString keyedByOracleId(const QString &oracleId, const QString &slugs) +{ + return QStringLiteral("{\"%1\": [%2]}").arg(oracleId, slugs); +} + +#ifdef HAS_ZLIB +QByteArray gzipCompress(const QByteArray &input) +{ + z_stream stream{}; + if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) { + return {}; + } + + stream.next_in = reinterpret_cast(const_cast(input.constData())); + stream.avail_in = static_cast(input.size()); + + QByteArray output; + char buffer[4096]; + int result = Z_OK; + while (result == Z_OK) { + stream.next_out = reinterpret_cast(buffer); + stream.avail_out = sizeof(buffer); + result = deflate(&stream, Z_FINISH); + output.append(buffer, static_cast(sizeof(buffer) - stream.avail_out)); + } + + deflateEnd(&stream); + return result == Z_STREAM_END ? output : QByteArray(); +} +#endif +} // namespace + +TEST(TagDatabaseTest, ParsesScryfallJsonLines) +{ + const QByteArray data = R"json( +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","parent_ids":[],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","weight":"median"}]} +{"object":"tag","id":"22222222-2222-2222-2222-222222222222","slug":"ramp","type":"oracle","parent_ids":[],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","weight":"median"},{"oracle_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","weight":"strong"}]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagCount(), 2); + EXPECT_EQ(database->taggedCardCount(), 2); + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_B, QString()), QStringList({"ramp"})); +} + +TEST(TagDatabaseTest, ParsesScryfallJsonArray) +{ + const QByteArray data = R"json([ +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +])json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} + +TEST(TagDatabaseTest, IgnoresNonOracleTagTypes) +{ + const QByteArray data = R"json( +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"22222222-2222-2222-2222-222222222222","slug":"art-only","type":"art","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} + +TEST(TagDatabaseTest, ParsesFlatOracleIdMap) +{ + const QByteArray data = keyedByOracleId(ORACLE_A, R"("Removal", "removal", " ramp ")").toUtf8(); + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // Mixed case, duplicates and surrounding whitespace are normalized. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_A.toUpper(), QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_B, QString()), QStringList()); +} + +TEST(TagDatabaseTest, ParsesFlatNameMap) +{ + const QByteArray data = R"json({"Lightning Bolt": ["removal", "burn"]})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // Name lookups are case-insensitive; an absent oracle id does not block them. + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("lightning bolt")), QStringList({"burn", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_A, QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, ParsesManifestWithNameKey) +{ + const QByteArray data = + R"json({"version": 2, "key": "name", "tags": {"Lightning Bolt": ["removal"], "zzz-not-a-uuid": ["burn"]}})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("Lightning Bolt")), QStringList({"removal"})); + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("ZZZ-not-a-uuid")), QStringList({"burn"})); +} + +TEST(TagDatabaseTest, ParsesJsonLinesRows) +{ + const QByteArray data = R"json( +{"oracle_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "tags": ["removal"]} +{"name": "Lightning Bolt", "tags": ["burn", "removal"]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, MergesOracleIdAndNameMatches) +{ + const QByteArray data = + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal"], "Lightning Bolt": ["burn", "removal"]})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, DropsSlugsContainingWhitespace) +{ + const QByteArray data = keyedByOracleId(ORACLE_A, R"("two words", "valid")").toUtf8(); + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // A slug with a space would corrupt the space-separated `tags` property. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"valid"})); +} + +TEST(TagDatabaseTest, ParentTagsAreOffByDefault) +{ + const QByteArray data = R"json( +{"object":"tag","id":"child","slug":"tutor-creature-giant","type":"oracle","parent_ids":["parent","missing"],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"parent","slug":"tutor-creature","type":"oracle","parent_ids":["grandparent"],"taggings":[]} +{"object":"tag","id":"grandparent","slug":"tutor","type":"oracle","parent_ids":[],"taggings":[]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"tutor-creature-giant"})); +} + +TEST(TagDatabaseTest, ParentTagsExpandTransitivelyWhenEnabled) +{ + const QByteArray data = R"json( +{"object":"tag","id":"child","slug":"tutor-creature-giant","type":"oracle","parent_ids":["parent","missing"],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"parent","slug":"tutor-creature","type":"oracle","parent_ids":["grandparent"],"taggings":[]} +{"object":"tag","id":"grandparent","slug":"tutor","type":"oracle","parent_ids":[],"taggings":[]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{.includeParentTags = true}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // The child precedes its parents in the file; expansion must not depend on order. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), + QStringList({"tutor", "tutor-creature", "tutor-creature-giant"})); +} + +TEST(TagDatabaseTest, RejectsEmptyData) +{ + QString error; + EXPECT_FALSE(load(QByteArray(), TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsMalformedJson) +{ + QString error; + EXPECT_FALSE(load("not a tag database", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsTagListsThatAreNotArrays) +{ + QString error; + EXPECT_FALSE(load(R"json({"tags": {"Lightning Bolt": "removal"}})json", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsDatabaseWithoutUsableTags) +{ + QString error; + EXPECT_FALSE(load(R"json({"Lightning Bolt": []})json", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +#ifdef HAS_ZLIB +TEST(TagDatabaseTest, DecompressesGzipPayloads) +{ + const QByteArray plain = keyedByOracleId(ORACLE_A, R"("removal")").toUtf8(); + const QByteArray compressed = gzipCompress(plain); + ASSERT_FALSE(compressed.isEmpty()); + ASSERT_EQ(compressed.left(2), QByteArray::fromHex("1f8b")); + + QString error; + const auto database = load(compressed, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} +#endif