diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp index 464d62e4a..46330ac46 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp @@ -15,9 +15,23 @@ #include #include +namespace +{ + +/** @brief Remove the filters that apply to the given color and attribute from the given model. */ +static void removeFilters(FilterTreeModel *filterModel, QChar color, CardFilter::Attr attr) +{ + for (const CardFilter *filter : filterModel->getFiltersOfType(attr)) { + if (filter->term() == color) { + filterModel->removeFilter(filter); + } + } +} +} // namespace + /** - * This widget provides a graphical control element for the CardFilter::Attr::AttrColor filters applied to the filter - * model. + * This widget provides a graphical control element for the CardFilter::Attr::AttrColor and + * CardFilter::Attr::AttrColorIdentity filters applied to the filter model. * @param parent The Qt Widget that this widget will parent to * @param _filterModel The filter model that this widget will manipulate */ @@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(true); modeComboBox->clear(); - modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes)); + modeComboBox->addItem(tr("Excludes"), QVariant::fromValue(FilterMode::Excludes)); modeComboBox->addItem(tr("Include / Exclude"), QVariant::fromValue(FilterMode::IncludeExclude)); + modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); + modeComboBox->addItem(tr("Color identity"), QVariant::fromValue(FilterMode::ColorIdentity)); + + modeComboBox->setItemData(0, tr("Cards containing any of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(1, tr("Cards containing none of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(2, tr("Cards containing any of the selected colors and none of the unselected colors"), + Qt::ToolTipRole); + modeComboBox->setItemData(3, tr("Cards being exactly of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(4, tr("Cards whose color identity fits within the selected colors"), Qt::ToolTipRole); + + if (currentMode == FilterMode::Custom) { + addCustomFilterModeItem(); + } modeComboBox->setToolTip(tr("How selected and unselected colors are combined in the filter")); + modeComboBox->setAccessibleName(tr("Color filter mode")); // Restore current mode const int index = modeComboBox->findData(QVariant::fromValue(currentMode)); @@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(false); } +void VisualDatabaseDisplayColorFilterWidget::setFilterMode(FilterMode filterMode) +{ + int index = modeComboBox->findData(QVariant::fromValue(filterMode)); + + if (index < 0 && filterMode == FilterMode::Custom) { + index = modeComboBox->count(); + addCustomFilterModeItem(); + } + + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } +} + +void VisualDatabaseDisplayColorFilterWidget::addCustomFilterModeItem() +{ + int customIndex = modeComboBox->count(); + modeComboBox->addItem(tr("Custom"), QVariant::fromValue(FilterMode::Custom)); + modeComboBox->setItemData(customIndex, tr("Cards matching an unknown combination of color filters"), + Qt::ToolTipRole); +} + void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active) { if (active) { @@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color) { - QString colorString = color; - QString typeStr; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove previous filters + removeFilters(filterModel, color, info.attr); - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; - - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add actual filter - - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeAnd, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; + if (info.includeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.includeType, info.attr)); } } void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color) { - QString colorString = color; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove inclusion filters - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; + removeFilters(filterModel, color, info.attr); - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add exclusion filters if the mode demands it - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - // No exclusion in Includes mode - break; + // Add exclusion filters if the mode demands it. + if (info.excludeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr)); } } @@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode() filterModel->filterTree()->blockSignals(true); filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor); + filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity); const QList manaSymbolWidgets = findChildren(); @@ -211,52 +212,53 @@ QList VisualDatabaseDisplayColorFilterWidget::getActiveColors() void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel() { - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); + QList filters; + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColor)); + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColorIdentity)); - QList manaSymbolWidgets = findChildren(); + // Re-derive the mode from the filters in the model, so that a loaded filter set selects the mode that + // produced it (or Custom when none could have). + currentMode = deriveFilterMode(filters, currentMode); - for (ManaSymbolWidget *manaSymbolWidget : manaSymbolWidgets) { + // Update the combobox. Add a Custom item only if we have a custom filter mode. + int customIndex = modeComboBox->findData(QVariant::fromValue(FilterMode::Custom)); + modeComboBox->blockSignals(true); + if (currentMode == FilterMode::Custom) { + if (customIndex < 0) { + customIndex = modeComboBox->count(); + addCustomFilterModeItem(); + } + modeComboBox->setCurrentIndex(customIndex); + } else { + if (customIndex >= 0) { + modeComboBox->removeItem(customIndex); + } + int index = modeComboBox->findData(QVariant::fromValue(currentMode)); + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } + } + modeComboBox->blockSignals(false); + + // Update the mana symbol widgets by looking for the current mode's include or exclude filters. + const FilterModeInfo modeInfo = getFilterModeInfo(currentMode); + + const bool useExclude = + modeInfo.includeType == CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd; + const CardFilter::Type filterType = useExclude ? modeInfo.excludeType : modeInfo.includeType; + + filters = filterModel->getFiltersOfType(modeInfo.attr); + + for (ManaSymbolWidget *manaSymbolWidget : findChildren()) { bool found = false; - for (const CardFilter *filter : allColorFilters) { - if (manaSymbolWidget->getSymbolChar() == filter->term()) { - switch (currentMode) { - case FilterMode::ExactMatch: - switch (filter->type()) { - case CardFilter::Type::TypeAnd: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::Includes: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::IncludeExclude: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - } + for (const CardFilter *filter : filters) { + if (filter->type() == filterType && manaSymbolWidget->getSymbolChar() == filter->term()) { found = true; + break; } } - if (!found) { - setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false); - } + manaSymbolWidget->setColorActive(useExclude ? !found : found); + manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom); } } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h index 184f1f96c..561c92d09 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h @@ -10,8 +10,8 @@ #include #include #include -#include #include +#include #include #include @@ -38,15 +38,6 @@ private: int circleDiameter; }; -enum class FilterMode -{ - ExactMatch, // Only selected colors are included, all others are excluded. - Includes, // Include selected colors (OR condition). - IncludeExclude // Include selected colors (OR) and exclude unselected colors (AND NOT). -}; - -Q_DECLARE_METATYPE(FilterMode) - class VisualDatabaseDisplayColorFilterWidget : public QWidget { Q_OBJECT @@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget public: explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel); void retranslateUi(); + void setFilterMode(FilterMode filterMode); signals: void filterModeChanged(FilterMode filterMode); private slots: + void addCustomFilterModeItem(); void handleColorToggled(QChar color, bool active); void addFilter(QChar color); void removeFilter(QChar color); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index af5c74969..6d9b28619 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -160,6 +160,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); + colorFilterWidget->setFilterMode(FilterMode::Includes); filterModel->clear(); filterModel->blockSignals(false); filterModel->filterTree()->blockSignals(false); diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 90bfd3f44..36f0cdeb8 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -316,6 +316,10 @@ void CardInfo::setColors(const QString &value) { setProperty(Mtg::Colors, value); } +QString CardInfo::getColorIdentity() const +{ + return getProperty(Mtg::ColorIdentity); +} QString CardInfo::getLoyalty() const { return getProperty(Mtg::Loyalty); diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 5452ef98b..075b5b264 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -469,6 +469,7 @@ public: [[nodiscard]] QString getCmc() const; [[nodiscard]] QString getColors() const; void setColors(const QString &value); + [[nodiscard]] QString getColorIdentity() const; [[nodiscard]] QString getLoyalty() const; [[nodiscard]] QString getMainCardType() const; [[nodiscard]] QString getManaCost() const; diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 070c04637..1b4303fc9 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -1,10 +1,10 @@ -set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h - libcockatrice/filters/filter_tree.h +set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_mode.h + libcockatrice/filters/filter_string.h libcockatrice/filters/filter_tree.h ) add_library( - libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_string.cpp - libcockatrice/filters/filter_tree.cpp + libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_mode.cpp + libcockatrice/filters/filter_string.cpp libcockatrice/filters/filter_tree.cpp ) add_dependencies(libcockatrice_filters libcockatrice_card) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp index 26cddd53c..8af39c098 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp @@ -69,6 +69,8 @@ const QString CardFilter::attrName(Attr a) return tr("Type"); case AttrColor: return tr("Color"); + case AttrColorIdentity: + return tr("Color Identity"); case AttrText: return tr("Text"); case AttrSet: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index dbeffd18f..bc613fc9c 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -32,6 +32,7 @@ public: { AttrCmc = 0, AttrColor, + AttrColorIdentity, AttrLoyalty, AttrManaCost, AttrName, diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp new file mode 100644 index 000000000..356ccf608 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp @@ -0,0 +1,77 @@ +/** + * @file filter_mode.cpp + * @ingroup Filters + */ + +#include "libcockatrice/filters/filter_mode.h" + +#include + +FilterModeInfo getFilterModeInfo(FilterMode filterMode) +{ + switch (filterMode) { + case FilterMode::Includes: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::Excludes: + return {CardFilter::Type::TypeAndNot, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::IncludeExclude: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ExactMatch: + return {CardFilter::Type::TypeAnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ColorIdentity: + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity}; + case FilterMode::Custom: + case FilterMode::End: + break; + } + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrEnd}; +} + +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode) +{ + constexpr int modeCount = static_cast(FilterMode::End); + bool modeImpossible[modeCount] = {}; + const QString colors = "WUBRG"; + + for (int i = 0; i < modeCount; ++i) { + const FilterModeInfo modeInfo = getFilterModeInfo(static_cast(i)); + + // Remove the modes that cannot create one of the filters. + for (const CardFilter *filter : filters) { + if (filter->attr() != modeInfo.attr || + (filter->type() != modeInfo.includeType && filter->type() != modeInfo.excludeType)) { + modeImpossible[i] = true; + break; + } + } + + // Modes that have both an include and an exclude filter should always have a filter for each color. + if (modeInfo.includeType != CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd && + !modeImpossible[i]) { + for (const QChar &color : colors) { + int count = 0; + for (const CardFilter *filter : filters) { + count += filter->term() == color; + } + if (count != 1) { + modeImpossible[i] = true; + break; + } + } + } + } + + // Keep the current mode if it can produce the filters, so that a live mode is never swapped for another. + // Switching to and from the custom mode is always possible. + if (!modeImpossible[static_cast(currentMode)] && currentMode != FilterMode::Custom) { + return currentMode; + } + + for (int i = 0; i < modeCount; ++i) { + if (!modeImpossible[i] || i == static_cast(FilterMode::Custom)) { + return static_cast(i); + } + } + + return FilterMode::Custom; +} diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.h b/libcockatrice_filters/libcockatrice/filters/filter_mode.h new file mode 100644 index 000000000..f7662d662 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.h @@ -0,0 +1,62 @@ +/** + * @file filter_mode.h + * @ingroup Filters + */ + +#ifndef LIBCOCKATRICE_FILTERS_FILTER_MODE_H +#define LIBCOCKATRICE_FILTERS_FILTER_MODE_H + +#include "libcockatrice/filters/filter_card.h" + +#include +#include +#include + +/** + * @brief The way selected and unselected colors are combined into card filters. + */ +enum class FilterMode +{ + Includes = 0, // Include selected colors (OR condition). + Excludes, // Exclude selected colors (AND NOT). + IncludeExclude, // Include selected colors (OR) and exclude unselected colors (AND NOT). + ExactMatch, // Only selected colors are included, all others are excluded. + ColorIdentity, // Keep cards whose color identity is a subset of the selected colors (stored as AND NOT for each + // unselected color). + Custom, // The color filters do not match any of the above modes. Not included in the combo box. + End +}; + +Q_DECLARE_METATYPE(FilterMode) + +/** + * @brief The filters a single color contributes in a given mode. + */ +struct FilterModeInfo +{ + // When a color symbol is enabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type includeType; + + // When a color symbol is disabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type excludeType; + + // The attribute to filter for when adding the filters. + CardFilter::Attr attr; +}; + +/** @brief Return the filters the given mode contributes for one color. */ +FilterModeInfo getFilterModeInfo(FilterMode filterMode); + +/** + * @brief Determine the mode that produced the given color filters. + * + * Returns @p currentMode unchanged when that mode can still produce @p filters, so that a live mode is never + * replaced by an equally valid one. Otherwise returns the first mode that can produce them, falling back to + * FilterMode::Custom when none can. + * + * @param filters The filters on CardFilter::Attr::AttrColor and CardFilter::Attr::AttrColorIdentity. + * @param currentMode The mode currently selected in the UI. + */ +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode); + +#endif // LIBCOCKATRICE_FILTERS_FILTER_MODE_H diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 081ada824..1bcb1646b 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -409,7 +409,7 @@ static void setupParserRules() const bool identity = sv.tokens[0].empty() || sv.tokens[0][0] != 'i'; if (sv.tokens[1][0] == ':') { return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("coloridentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } @@ -427,7 +427,7 @@ static void setupParserRules() } return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index f12f87181..817c7298d 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -176,23 +176,24 @@ FilterItem::FilterItem(QString trm, FilterItemList *parent) : p(parent), term(st lowercaseTerm = term.toLower(); switch (attr()) { - case CardFilter::AttrColor: { - QString converted = term.trimmed(); + case CardFilter::AttrColor: + case CardFilter::AttrColorIdentity: { + QString converted = term.trimmed().toLower(); - converted.replace("green", "g", Qt::CaseInsensitive); - converted.replace("grn", "g", Qt::CaseInsensitive); - converted.replace("blue", "u", Qt::CaseInsensitive); - converted.replace("blu", "u", Qt::CaseInsensitive); - converted.replace("black", "b", Qt::CaseInsensitive); - converted.replace("blk", "b", Qt::CaseInsensitive); - converted.replace("red", "r", Qt::CaseInsensitive); - converted.replace("white", "w", Qt::CaseInsensitive); - converted.replace("wht", "w", Qt::CaseInsensitive); - converted.replace("colorless", "c", Qt::CaseInsensitive); - converted.replace("colourless", "c", Qt::CaseInsensitive); - converted.replace("none", "c", Qt::CaseInsensitive); + converted.replace("green", "g"); + converted.replace("grn", "g"); + converted.replace("blue", "u"); + converted.replace("blu", "u"); + converted.replace("black", "b"); + converted.replace("blk", "b"); + converted.replace("red", "r"); + converted.replace("white", "w"); + converted.replace("wht", "w"); + converted.replace("colorless", "c"); + converted.replace("colourless", "c"); + converted.replace("none", "c"); - converted.replace(QString(" "), QString(""), Qt::CaseInsensitive); + converted.replace(QString(" "), QString("")); convertedColorTerm = converted; break; @@ -358,12 +359,17 @@ bool FilterItem::acceptSubType(const CardInfoPtr &info) const return subType.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptColor(const CardInfoPtr &info) const +/** + * @brief Return true when every letter of the given term appears in the given colors string. + * If the term is equal to "c", then only return true if the color string is empty. + * + * @param term The letters which should be included, or "c" + * @param colors The string to check for letters in + */ +static bool matchesColor(const QString &term, const QString &colors) { - const QString colors = info->getColors(); - // Colorless card filter - if (convertedColorTerm.toLower() == "c" && colors.length() < 1) { + if (term == "c" && colors.length() < 1) { return true; } @@ -372,13 +378,23 @@ bool FilterItem::acceptColor(const CardInfoPtr &info) const * then we should match all of them to the card's colors */ int match_count = 0; - for (const QChar &color : convertedColorTerm) { + for (const QChar &color : term) { if (colors.contains(color, Qt::CaseInsensitive)) { match_count++; } } - return match_count == convertedColorTerm.length(); + return match_count == term.length(); +} + +bool FilterItem::acceptColor(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColors()); +} + +bool FilterItem::acceptColorIdentity(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColorIdentity()); } bool FilterItem::acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const @@ -540,6 +556,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info, return acceptType(info); case CardFilter::AttrColor: return acceptColor(info); + case CardFilter::AttrColorIdentity: + return acceptColorIdentity(info); case CardFilter::AttrText: return acceptText(info, searchLanguage); case CardFilter::AttrSet: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index 85a57304d..f1a5803e5 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -244,6 +244,7 @@ public: [[nodiscard]] bool acceptMainType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptSubType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptColor(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptColorIdentity(const CardInfoPtr &info) const; [[nodiscard]] bool acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptSet(const CardInfoPtr &info) const; [[nodiscard]] bool acceptManaCost(const CardInfoPtr &info) const; diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 7d83640a1..012081fd2 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -21,10 +21,12 @@ cockatrice_add_test( ) # ------------------------ -# Filter String Test -# (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt) +# Guard to match condition for libcockatrice_filters in the root CMakeLists.txt # ------------------------ if(WITH_ORACLE OR WITH_CLIENT) + # ------------------------ + # Filter String Test + # ------------------------ cockatrice_add_test( NAME filter_string_test @@ -36,6 +38,34 @@ if(WITH_ORACLE OR WITH_CLIENT) libcockatrice_filters ) + # ------------------------ + # Filter Tree Test + # ------------------------ + cockatrice_add_test( + NAME + filter_tree_test + SOURCES + ${VERSION_STRING_CPP} + filter_tree_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + + # ------------------------ + # Filter Mode Test + # ------------------------ + cockatrice_add_test( + NAME + filter_mode_test + SOURCES + ${VERSION_STRING_CPP} + filter_mode_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + # ------------------------ # Visual Display Model Test # ------------------------ diff --git a/tests/carddatabase/filter_mode_test.cpp b/tests/carddatabase/filter_mode_test.cpp new file mode 100644 index 000000000..3fec7e25d --- /dev/null +++ b/tests/carddatabase/filter_mode_test.cpp @@ -0,0 +1,147 @@ +#include "libcockatrice/filters/filter_mode.h" + +#include + +class FilterModeTest : public ::testing::Test +{ +protected: + ~FilterModeTest() override + { + for (CardFilter *filter : owned) { + delete filter; + } + } + + /** @brief Create a filter owned by the fixture and append it to the set under test. */ + const CardFilter * + add(const QString &term, CardFilter::Type type, CardFilter::Attr attr = CardFilter::Attr::AttrColor) + { + owned.append(new CardFilter(term, type, attr)); + filters.append(owned.last()); + return owned.last(); + } + + QList owned; + QList filters; +}; + +TEST(FilterModeInfoTest, EachModeDescribesItsOwnFilters) +{ + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).excludeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).attr, CardFilter::Attr::AttrColor); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).includeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).excludeType, CardFilter::Type::TypeEnd); + + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).excludeType, CardFilter::Type::TypeAndNot); + + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).includeType, CardFilter::Type::TypeAnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).excludeType, CardFilter::Type::TypeAndNot); + + // Color identity only excludes: the selected colors must be the identity, so nothing is included positively. + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).includeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).excludeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).attr, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Custom).attr, CardFilter::Attr::AttrEnd); +} + +TEST_F(FilterModeTest, EmptyFilterSetKeepsACompatibleMode) +{ + // Includes and Color identity both allow an empty set, so the live mode is not swapped for another. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +// Include / Exclude and Exact match always hold one filter per color, so an empty set cannot have come from +// them and falls back to the first mode that can describe it. +TEST_F(FilterModeTest, EmptyFilterSetFallsBackWhenTheModeNeedsOneFilterPerColor) +{ + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::Includes); +} + +TEST_F(FilterModeTest, IncludesFromOrFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + // A single OR filter cannot come from Excludes, so the first mode that can produce it is selected. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Includes); +} + +TEST_F(FilterModeTest, ExcludesFromAndNotFilters) +{ + add("W", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Excludes); + // Includes needs an OR or an end filter, so an AND NOT filter selects Excludes. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} + +TEST_F(FilterModeTest, IncludeExcludeNeedsOneFilterPerColor) +{ + add("W", CardFilter::Type::TypeOr); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::IncludeExclude); +} + +TEST_F(FilterModeTest, IncludeExcludeRejectsAnIncompleteColorSet) +{ + add("W", CardFilter::Type::TypeOr); + add("U", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Custom); +} + +TEST_F(FilterModeTest, ExactMatchFromAndAndAndNotFilters) +{ + add("W", CardFilter::Type::TypeAnd); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); +} + +TEST_F(FilterModeTest, ColorIdentityFromIdentityAndNotFilters) +{ + for (const QChar color : QString("BRG")) { + add(QString(color), CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +TEST_F(FilterModeTest, MixedAttributesAreCustom) +{ + add("W", CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor); + add("U", CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Custom); +} + +TEST_F(FilterModeTest, CustomLeavesAsSoonAsAModeCanExplainTheFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Custom), FilterMode::Includes); +} + +// Exact match with no color selected stores one AND NOT filter per color. That is a legitimate Exact match +// state, so the live mode is kept; a mode that cannot produce the set re-derives to Excludes, which produces +// the identical filters. +TEST_F(FilterModeTest, FullyExcludedColorSetIsExactMatchOrExcludes) +{ + for (const QChar color : QString("WUBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index e561db590..931cb7e0c 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -123,6 +123,25 @@ CardInfoPtr greenIdentityCard() return CardInfo::newInstance("Identity Cat", "", false, {{"colors", "G"}, {"coloridentity", "G"}}, {}, {}, {}, {}); } +// A card whose color identity differs from its colors, so that a query reading the wrong one is visible. +CardInfoPtr splitIdentityCard() +{ + return CardInfo::newInstance("Split Identity", "", false, {{"colors", "G"}, {"coloridentity", "W"}}, {}, {}, {}, + {}); +} + +// "ci!" (exactly these color identities) used to read the camelCase "colorIdentity" property instead of the +// lowercase "coloridentity" key, so it matched the empty string and rejected every card. +TEST_F(CardQuery, ColorIdentityExactReadsTheColorIdentityProperty) +{ + const CardData card = splitIdentityCard(); + ASSERT_TRUE(FilterString("ci!w").check(card)); + ASSERT_FALSE(FilterString("ci!g").check(card)); + // "c!" still reads colors, not identity. + ASSERT_TRUE(FilterString("c!g").check(card)); + ASSERT_FALSE(FilterString("c!w").check(card)); +} + // Whitespace belongs around the separator, never inside an operator token: "c i: g" must not // be read as the color-identity operator "ci" (it is a generic "c" AND a field query on "i"). TEST_F(CardQuery, ColorIdentityOperatorIsNotSplitByWhitespace) diff --git a/tests/carddatabase/filter_tree_test.cpp b/tests/carddatabase/filter_tree_test.cpp new file mode 100644 index 000000000..8d3706071 --- /dev/null +++ b/tests/carddatabase/filter_tree_test.cpp @@ -0,0 +1,204 @@ + +#include "libcockatrice/filters/filter_tree.h" + +#include +#include +#include + +namespace +{ +class FilterTreeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + language = CardSearchLanguage{}; + + island = CardInfo::newInstance("Island", "", false, {{"colors", ""}, {"coloridentity", "U"}}, {}, {}, {}, {}); + artifact = + CardInfo::newInstance("Artifact", "", false, {{"colors", ""}, {"coloridentity", ""}}, {}, {}, {}, {}); + truth = CardInfo::newInstance("Truth", "", false, {{"colors", "U"}, {"coloridentity", "U"}}, {}, {}, {}, {}); + notDead = + CardInfo::newInstance("Not Dead", "", false, {{"colors", "B"}, {"coloridentity", "B"}}, {}, {}, {}, {}); + wallOfDenial = CardInfo::newInstance("Wall of Denial", "", false, {{"colors", "WU"}, {"coloridentity", "WU"}}, + {}, {}, {}, {}); + maelstromAngel = CardInfo::newInstance("Maelstrom Archangel", "", false, + {{"colors", "WUBRG"}, {"coloridentity", "WUBRG"}}, {}, {}, {}, {}); + } + + CardSearchLanguage language; + + CardData island; // c/u + CardData artifact; // c + CardData truth; // u + CardData notDead; // b + CardData wallOfDenial; // wu + CardData maelstromAngel; // wubrg +}; +} // namespace + +// ===================================================================================================================== +// acceptColor +// ===================================================================================================================== + +TEST_F(FilterTreeTest, Color) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "c"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorAliases) +{ + FilterTree filterTree; + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "green"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "grn"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blue"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blu"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "black"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blk"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "red"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "white"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wht"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colorless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colourless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "none"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); +} + +// ===================================================================================================================== +// acceptColorIdentity +// ===================================================================================================================== + +TEST_F(FilterTreeTest, ColorIdentity) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "c"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityMultiple) +{ + // Simulate a WU commander. + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "r"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "g"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}