[Client] Added an exclude and color identity filter for the visual database display (#7587)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 sanitize (push) Blocked by required conditions
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 27 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions

* [Client] Add exclude and color identity filters to the visual database display

* [Filters] Extract the color filter mode derivation into libcockatrice_filters

The mode table and the 'which mode produced this filter set' derivation are
filter semantics, not widget code, so they belong next to CardFilter rather
than in an anonymous namespace in the client. Moving them also makes them
testable: filter_mode_test pins every mode, the Custom fallback and the
'keep the live mode when it still fits' rule.

* [Tests] Cover the negated color identity query

ci! read the camelCase colorIdentity property instead of the lowercase
coloridentity key, so it matched the empty string and rejected every card.
The new case uses a card whose colors and color identity differ, so it fails
on the old lookup and passes on the fixed one.

---------

Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com>
This commit is contained in:
Jurriaan 2026-11-05 12:40:32 +01:00 • committed by GitHub
parent 64c7809332
commit 74686d1736
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 703 additions and 141 deletions

View file

@ -15,9 +15,23 @@
#include <QtGlobal>
#include <libcockatrice/filters/filter_tree.h>
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<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
QList<const CardFilter *> 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<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
QList<const CardFilter *> 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<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>();
@ -211,52 +212,53 @@ QList<QChar> VisualDatabaseDisplayColorFilterWidget::getActiveColors()
void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel()
{
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
QList<const CardFilter *> filters;
filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColor));
filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColorIdentity));
QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>();
// 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<ManaSymbolWidget *>()) {
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);
}
}

View file

@ -10,8 +10,8 @@
#include <QByteArray>
#include <QChar>
#include <QList>
#include <QMetaType>
#include <QWidget>
#include <libcockatrice/filters/filter_mode.h>
#include <libcockatrice/utility/debouncer.h>
#include <qtmetamacros.h>
@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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)

View file

@ -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:

View file

@ -32,6 +32,7 @@ public:
{
AttrCmc = 0,
AttrColor,
AttrColorIdentity,
AttrLoyalty,
AttrManaCost,
AttrName,

View file

@ -0,0 +1,77 @@
/**
* @file filter_mode.cpp
* @ingroup Filters
*/
#include "libcockatrice/filters/filter_mode.h"
#include <QString>
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<const CardFilter *> &filters, FilterMode currentMode)
{
constexpr int modeCount = static_cast<int>(FilterMode::End);
bool modeImpossible[modeCount] = {};
const QString colors = "WUBRG";
for (int i = 0; i < modeCount; ++i) {
const FilterModeInfo modeInfo = getFilterModeInfo(static_cast<FilterMode>(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<int>(currentMode)] && currentMode != FilterMode::Custom) {
return currentMode;
}
for (int i = 0; i < modeCount; ++i) {
if (!modeImpossible[i] || i == static_cast<int>(FilterMode::Custom)) {
return static_cast<FilterMode>(i);
}
}
return FilterMode::Custom;
}

View file

@ -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 <QChar>
#include <QList>
#include <QMetaType>
/**
* @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<const CardFilter *> &filters, FilterMode currentMode);
#endif // LIBCOCKATRICE_FILTERS_FILTER_MODE_H

View file

@ -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;
}

View file

@ -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:

View file

@ -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;

View file

@ -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
# ------------------------

View file

@ -0,0 +1,147 @@
#include "libcockatrice/filters/filter_mode.h"
#include <gtest/gtest.h>
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<CardFilter *> owned;
QList<const CardFilter *> 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);
}

View file

@ -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)

View file

@ -0,0 +1,204 @@
#include "libcockatrice/filters/filter_tree.h"
#include <gtest/gtest.h>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/filters/filter_string.h>
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();
}