mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-11 20:43:25 -08:00
Merge upstream/master into scryfall-tagger-tags
Conflict resolution: keep both the tag database and the upstream database writer in the oracle test targets and both includes in the importer test; restore the tags.svg resource entry that the rerere replay dropped. Upstream's icon rules also landed mid-branch: use loadThemedIcon() for the tag quick-filter button and re-author tags.svg as a 24x24 currentColor icon so IconAssets.AssetsFollowTheRules passes.
This commit is contained in:
commit
f154335f5c
553 changed files with 21045 additions and 11980 deletions
|
|
@ -171,13 +171,37 @@ endfunction()
|
|||
|
||||
# --- Unit tests ------------------------------------------------------------------
|
||||
|
||||
cockatrice_add_test(NAME image_request_queue_test SOURCES image_request_queue_test.cpp)
|
||||
cockatrice_add_test(NAME image_redirect_chain_test SOURCES image_redirect_chain_test.cpp)
|
||||
cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME svg_recolor_test SOURCES svg_recolor_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
user_games_model_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/server/user/user_games_model.cpp
|
||||
user_games_model_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network_client_abstract
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
${CMAKE_SOURCE_DIR}/libcockatrice_network
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
spoiler_file_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp
|
||||
spoiler_file_test.cpp
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
)
|
||||
# The installer's holder probe is Windows-only, but the wording it produces and the
|
||||
# question which programs are the payload's own are plain C++ and are what a stuck user
|
||||
# has to act on, so they are tested everywhere.
|
||||
|
|
@ -208,6 +232,18 @@ cockatrice_add_test(
|
|||
libcockatrice_deck_list
|
||||
libcockatrice_utility
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
card_art_crop_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/cards/card_art_utils.cpp
|
||||
${VERSION_STRING_CPP}
|
||||
card_art_crop_test.cpp
|
||||
LIBS
|
||||
libcockatrice_card
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
server_rate_limiter_test
|
||||
|
|
@ -266,6 +302,7 @@ cockatrice_add_test(
|
|||
NAME
|
||||
server_card_counter_test
|
||||
SOURCES
|
||||
${VERSION_STRING_CPP}
|
||||
server_card_counter_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network
|
||||
|
|
@ -276,6 +313,7 @@ cockatrice_add_test(
|
|||
NAME
|
||||
server_dump_zone_test
|
||||
SOURCES
|
||||
${VERSION_STRING_CPP}
|
||||
server_dump_zone_test.cpp
|
||||
LIBS
|
||||
libcockatrice_rng
|
||||
|
|
@ -287,6 +325,7 @@ cockatrice_add_test(
|
|||
NAME
|
||||
server_counter_test
|
||||
SOURCES
|
||||
${VERSION_STRING_CPP}
|
||||
server_counter_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network
|
||||
|
|
@ -298,6 +337,7 @@ cockatrice_add_test(
|
|||
server_developer_role_test
|
||||
NO_SANITIZE
|
||||
SOURCES
|
||||
${VERSION_STRING_CPP}
|
||||
server_developer_role_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network
|
||||
|
|
@ -310,6 +350,7 @@ cockatrice_add_test(
|
|||
server_moderator_role_test
|
||||
NO_SANITIZE
|
||||
SOURCES
|
||||
${VERSION_STRING_CPP}
|
||||
server_moderator_role_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network
|
||||
|
|
@ -317,6 +358,17 @@ cockatrice_add_test(
|
|||
LABELS
|
||||
server
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
random_reveal_test
|
||||
SOURCES
|
||||
random_reveal_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network_server_remote
|
||||
libcockatrice_rng
|
||||
LABELS
|
||||
server
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
server_game_join_test
|
||||
|
|
@ -356,6 +408,7 @@ cockatrice_add_test(
|
|||
NAME
|
||||
local_client_lifetime_test
|
||||
SOURCES
|
||||
${VERSION_STRING_CPP}
|
||||
local_client_lifetime_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network
|
||||
|
|
@ -377,6 +430,7 @@ cockatrice_add_test(
|
|||
NAME
|
||||
latency_tracker_test
|
||||
SOURCES
|
||||
${VERSION_STRING_CPP}
|
||||
latency_tracker_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network
|
||||
|
|
@ -414,18 +468,35 @@ cockatrice_add_test(
|
|||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
loader_local_matching_test
|
||||
font_config_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
|
||||
${VERSION_STRING_CPP}
|
||||
loader_local_matching_test.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp
|
||||
font_config_test.cpp
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
game_creation_options_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp
|
||||
game_creation_options_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network
|
||||
libcockatrice_settings
|
||||
libcockatrice_interfaces
|
||||
libcockatrice_protocol
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
create_game_form_widget_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp
|
||||
create_game_form_widget_test.cpp
|
||||
LIBS
|
||||
libcockatrice_interfaces
|
||||
libcockatrice_protocol
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
)
|
||||
|
|
@ -480,6 +551,19 @@ endif()
|
|||
|
||||
# Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt.
|
||||
if(WITH_CLIENT)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
moxfield_signature_spells_test
|
||||
SOURCES
|
||||
${VERSION_STRING_CPP}
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_loader/card_node_function.cpp
|
||||
moxfield_signature_spells_test.cpp
|
||||
LIBS
|
||||
libcockatrice_card
|
||||
libcockatrice_deck_list
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
single_instance_manager_test
|
||||
|
|
@ -527,6 +611,7 @@ add_subdirectory(card_zone_algorithms)
|
|||
add_subdirectory(carddatabase)
|
||||
add_subdirectory(deck_list_model)
|
||||
add_subdirectory(deck_list_zones)
|
||||
add_subdirectory(icon_assets)
|
||||
add_subdirectory(loading_from_clipboard)
|
||||
add_subdirectory(model_contract)
|
||||
add_subdirectory(movecard_tests)
|
||||
|
|
@ -548,3 +633,46 @@ endif()
|
|||
if(WITH_ORACLE OR WITH_CLIENT)
|
||||
add_subdirectory(picture_loader_benchmark)
|
||||
endif()
|
||||
|
||||
# loader_local_matching_test and deck_state_manager_test links libcockatrice_settings,
|
||||
# which only exists when a client/UI-capable target is being built.
|
||||
if(WITH_ORACLE OR WITH_CLIENT)
|
||||
set(SETTINGS_SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
|
||||
${VERSION_STRING_CPP}
|
||||
)
|
||||
set(SETTINGS_LIBS libcockatrice_network libcockatrice_settings)
|
||||
set(SETTINGS_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src)
|
||||
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
loader_local_matching_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
|
||||
${SETTINGS_SOURCES}
|
||||
loader_local_matching_test.cpp
|
||||
LIBS
|
||||
${SETTINGS_LIBS}
|
||||
INCLUDE_DIRS
|
||||
${SETTINGS_INCLUDE_DIRS}
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
deck_state_manager_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp
|
||||
${SETTINGS_SOURCES}
|
||||
deck_state_manager_test.cpp
|
||||
LIBS
|
||||
libcockatrice_card
|
||||
libcockatrice_deck_list
|
||||
libcockatrice_models
|
||||
libcockatrice_utility
|
||||
${SETTINGS_LIBS}
|
||||
INCLUDE_DIRS
|
||||
${SETTINGS_INCLUDE_DIRS}
|
||||
)
|
||||
endif()
|
||||
|
|
|
|||
92
tests/card_art_crop_test.cpp
Normal file
92
tests/card_art_crop_test.cpp
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#include "interface/widgets/cards/card_art_utils.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QTransform>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/card/card_info.h>
|
||||
#include <libcockatrice/card/printing/exact_card.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
ExactCard makeCard(bool landscape)
|
||||
{
|
||||
CardInfo::UiAttributes attributes;
|
||||
attributes.landscapeOrientation = landscape;
|
||||
return ExactCard(CardInfo::newInstance("Artwork", "", false, {}, {}, {}, {}, attributes));
|
||||
}
|
||||
|
||||
QPixmap landscapeCardWithFrame()
|
||||
{
|
||||
// Normalized landscape frame: the title reaches 14% down the upright
|
||||
// image, and the Battle type line starts at 56%. Artwork has two colors
|
||||
// so the test also detects a wrong or missing rotation.
|
||||
QImage upright(1400, 1000, QImage::Format_RGB32);
|
||||
upright.fill(Qt::red);
|
||||
QPainter painter(&upright);
|
||||
painter.fillRect(QRect(70, 140, 1260, 420), Qt::green);
|
||||
painter.fillRect(QRect(700, 140, 630, 420), Qt::blue);
|
||||
painter.end();
|
||||
QTransform storedOrientation;
|
||||
storedOrientation.rotate(-90);
|
||||
return QPixmap::fromImage(upright.transformed(storedOrientation));
|
||||
}
|
||||
|
||||
TEST(CardArtCrop, LandscapeCropExcludesNameAndRulesBoxesAndRotatesUpright)
|
||||
{
|
||||
const QImage crop = CardArtUtils::cropCardArt(landscapeCardWithFrame(), makeCard(true)).toImage();
|
||||
ASSERT_GT(crop.width(), crop.height());
|
||||
ASSERT_FALSE(crop.isNull());
|
||||
for (int y = 0; y < crop.height(); ++y) {
|
||||
for (int x = 0; x < crop.width(); ++x) {
|
||||
EXPECT_EQ(x < crop.width() / 2 ? QColor(Qt::green) : QColor(Qt::blue), crop.pixelColor(x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(CardArtCrop, PortraitCropRetainsItsExistingArtworkBounds)
|
||||
{
|
||||
QImage source(745, 1040, QImage::Format_RGB32);
|
||||
for (int y = 0; y < source.height(); ++y) {
|
||||
for (int x = 0; x < source.width(); ++x) {
|
||||
source.setPixelColor(x, y, QColor(x % 256, y % 256, (x + y) % 256));
|
||||
}
|
||||
}
|
||||
const QPixmap input = QPixmap::fromImage(source);
|
||||
const QImage crop = CardArtUtils::cropCardArt(input, makeCard(false)).toImage();
|
||||
EXPECT_EQ(source.copy(QRect(52, 114, 641, 458)), crop);
|
||||
}
|
||||
|
||||
TEST(CardArtCrop, DevicePixelRatioDoesNotChangeTheArtworkPixels)
|
||||
{
|
||||
QPixmap source = landscapeCardWithFrame();
|
||||
const QPixmap normal = CardArtUtils::cropCardArt(source, makeCard(true));
|
||||
source.setDevicePixelRatio(2.0);
|
||||
const QPixmap highDpi = CardArtUtils::cropCardArt(source, makeCard(true));
|
||||
EXPECT_EQ(normal.size(), highDpi.size());
|
||||
EXPECT_EQ(2.0, highDpi.devicePixelRatio());
|
||||
QImage normalPixels = normal.toImage();
|
||||
QImage highDpiPixels = highDpi.toImage();
|
||||
normalPixels.setDevicePixelRatio(1.0);
|
||||
highDpiPixels.setDevicePixelRatio(1.0);
|
||||
EXPECT_EQ(normalPixels, highDpiPixels);
|
||||
}
|
||||
|
||||
TEST(CardArtCrop, EmptyAndSmallImagesStayWithinTheirBounds)
|
||||
{
|
||||
EXPECT_TRUE(CardArtUtils::cropCardArt({}, makeCard(true)).isNull());
|
||||
QPixmap small(1, 1);
|
||||
small.fill(Qt::green);
|
||||
EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(true)).toImage());
|
||||
EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(false)).toImage());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
QApplication app(argc, argv);
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
|
@ -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
|
||||
# ------------------------
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
#include "../support/scoped_file_size_limit.h"
|
||||
#include "libcockatrice/card/database/card_database.h"
|
||||
#include "libcockatrice/card/database/card_database_loader.h"
|
||||
#include "libcockatrice/card/database/card_database_querier.h"
|
||||
|
|
@ -204,6 +205,57 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip)
|
|||
ASSERT_EQ(printings.first().getProperty("manacost"), "1R");
|
||||
}
|
||||
|
||||
#ifdef Q_OS_UNIX
|
||||
/**
|
||||
* @brief A failed write keeps the previously installed card database intact.
|
||||
*
|
||||
* This is the writer Oracle uses to install a refreshed database, so the truncation
|
||||
* bug has to be covered here and not only through OracleDatabase::saveXml.
|
||||
*/
|
||||
TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile)
|
||||
{
|
||||
NoopCardSetPriorityController controller;
|
||||
CardSetPtr set =
|
||||
CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary);
|
||||
|
||||
QHash<QString, QString> props;
|
||||
props["manacost"] = "1R";
|
||||
PrintingInfo printing(set, LazyPropertiesHash(props));
|
||||
SetToPrintingsMap setsInfo;
|
||||
setsInfo["TST"].append(printing);
|
||||
|
||||
CardInfo::UiAttributes attributes = {.tableRow = 1};
|
||||
CardInfoPtr card =
|
||||
CardInfo::newInstance("Lightning Bolt", QString(1024, 'x'), false, {}, {}, {}, setsInfo, attributes);
|
||||
|
||||
SetNameMap sets;
|
||||
sets.insert("TST", set);
|
||||
CardNameMap cards;
|
||||
cards.insert("Lightning Bolt", card);
|
||||
|
||||
QTemporaryDir tempDir;
|
||||
ASSERT_TRUE(tempDir.isValid());
|
||||
const QString fileName = tempDir.filePath("cards.xml");
|
||||
|
||||
QFile installed(fileName);
|
||||
ASSERT_TRUE(installed.open(QIODevice::WriteOnly));
|
||||
ASSERT_EQ(installed.write("installed database"), 18);
|
||||
installed.close();
|
||||
|
||||
// A real filesystem write failure: the soft file-size cap makes the writer's own
|
||||
// flush fail without filling a disk. The cap is scoped rather than forked, because a
|
||||
// death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS.
|
||||
test_support::ScopedFileSizeLimit fileSizeCap(128);
|
||||
ASSERT_TRUE(fileSizeCap.isValid());
|
||||
|
||||
CockatriceXml4Parser writer(&controller);
|
||||
EXPECT_FALSE(writer.saveToFile({}, sets, cards, fileName));
|
||||
|
||||
ASSERT_TRUE(installed.open(QIODevice::ReadOnly));
|
||||
EXPECT_EQ(installed.readAll(), "installed database");
|
||||
}
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Path provider serving a sandboxed copy of the test card database.
|
||||
*/
|
||||
|
|
|
|||
147
tests/carddatabase/filter_mode_test.cpp
Normal file
147
tests/carddatabase/filter_mode_test.cpp
Normal 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);
|
||||
}
|
||||
|
|
@ -124,6 +124,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)
|
||||
|
|
@ -319,6 +338,22 @@ TEST_F(SetQuery, ComparisonMatchesByReleaseDate)
|
|||
EXPECT_FALSE(FilterString("set<EOE").check(inBothSets));
|
||||
}
|
||||
|
||||
TEST(FilterStringError, InvalidExpressionIsInvalidAndReportsAPosition)
|
||||
{
|
||||
const FilterString filter("t:(");
|
||||
|
||||
EXPECT_FALSE(filter.valid());
|
||||
EXPECT_TRUE(filter.error().contains("position")) << "unexpected error text: " << filter.error().toStdString();
|
||||
}
|
||||
|
||||
TEST(FilterStringError, ValidExpressionHasNoError)
|
||||
{
|
||||
const FilterString filter("t:creature");
|
||||
|
||||
EXPECT_TRUE(filter.valid());
|
||||
EXPECT_TRUE(filter.error().isEmpty());
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
|
|
|
|||
204
tests/carddatabase/filter_tree_test.cpp
Normal file
204
tests/carddatabase/filter_tree_test.cpp
Normal 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();
|
||||
}
|
||||
|
|
@ -13,6 +13,10 @@
|
|||
#include <QMetaObject>
|
||||
#include <QString>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
|
||||
// Referenced by other translation units in the server_remote library.
|
||||
RNG_Abstract *rng = nullptr;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
|
|
|||
132
tests/create_game_form_widget_test.cpp
Normal file
132
tests/create_game_form_widget_test.cpp
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#include "interface/widgets/game_hosting/create_game_form_widget.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QMap>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
const QMap<int, QString> gameTypes{{1, "Draft"}, {2, "Commander"}};
|
||||
|
||||
void expectSameOptions(const GameCreationOptions &actual, const GameCreationOptions &expected)
|
||||
{
|
||||
EXPECT_EQ(actual.description, expected.description);
|
||||
EXPECT_EQ(actual.password, expected.password);
|
||||
EXPECT_EQ(actual.maxPlayers, expected.maxPlayers);
|
||||
EXPECT_EQ(actual.onlyBuddies, expected.onlyBuddies);
|
||||
EXPECT_EQ(actual.onlyRegistered, expected.onlyRegistered);
|
||||
EXPECT_EQ(actual.spectatorsAllowed, expected.spectatorsAllowed);
|
||||
EXPECT_EQ(actual.spectatorsNeedPassword, expected.spectatorsNeedPassword);
|
||||
EXPECT_EQ(actual.spectatorsCanTalk, expected.spectatorsCanTalk);
|
||||
EXPECT_EQ(actual.spectatorsSeeEverything, expected.spectatorsSeeEverything);
|
||||
EXPECT_EQ(actual.joinAsJudge, expected.joinAsJudge);
|
||||
EXPECT_EQ(actual.joinAsSpectator, expected.joinAsSpectator);
|
||||
EXPECT_EQ(actual.startingLifeTotal, expected.startingLifeTotal);
|
||||
EXPECT_EQ(actual.shareDecklistsOnLoad, expected.shareDecklistsOnLoad);
|
||||
EXPECT_EQ(actual.shufflePlayers, expected.shufflePlayers);
|
||||
EXPECT_EQ(actual.gameTypeIds, expected.gameTypeIds);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(CreateGameFormWidget, RegisteredHostDefaultsToRegisteredOnly)
|
||||
{
|
||||
const CreateGameFormWidget form(gameTypes, true, false);
|
||||
|
||||
const GameCreationOptions options = form.options();
|
||||
|
||||
EXPECT_TRUE(options.onlyRegistered);
|
||||
EXPECT_FALSE(options.onlyBuddies);
|
||||
EXPECT_EQ(options.maxPlayers, 2);
|
||||
EXPECT_TRUE(options.spectatorsAllowed);
|
||||
EXPECT_EQ(options.startingLifeTotal, 20);
|
||||
EXPECT_TRUE(options.gameTypeIds.isEmpty());
|
||||
}
|
||||
|
||||
TEST(CreateGameFormWidget, UnregisteredHostDefaultsToOpenJoin)
|
||||
{
|
||||
const CreateGameFormWidget form(gameTypes, false, false);
|
||||
|
||||
const GameCreationOptions options = form.options();
|
||||
|
||||
EXPECT_FALSE(options.onlyRegistered);
|
||||
EXPECT_FALSE(options.onlyBuddies);
|
||||
}
|
||||
|
||||
TEST(CreateGameFormWidget, SetOptionsRoundTripsEveryField)
|
||||
{
|
||||
CreateGameFormWidget form(gameTypes, true, true);
|
||||
|
||||
GameCreationOptions expected;
|
||||
expected.description = "Some game";
|
||||
expected.password = "hunter2";
|
||||
expected.maxPlayers = 5;
|
||||
expected.onlyBuddies = true;
|
||||
expected.onlyRegistered = false;
|
||||
expected.spectatorsAllowed = false;
|
||||
expected.spectatorsNeedPassword = true;
|
||||
expected.spectatorsCanTalk = true;
|
||||
expected.spectatorsSeeEverything = true;
|
||||
expected.joinAsJudge = true;
|
||||
expected.joinAsSpectator = true;
|
||||
expected.startingLifeTotal = 40;
|
||||
expected.shareDecklistsOnLoad = true;
|
||||
expected.shufflePlayers = true;
|
||||
expected.gameTypeIds = {2};
|
||||
|
||||
form.setOptions(expected);
|
||||
|
||||
expectSameOptions(form.options(), expected);
|
||||
}
|
||||
|
||||
TEST(CreateGameFormWidget, ResetRestoresDefaultsAndClearsTheGameType)
|
||||
{
|
||||
CreateGameFormWidget form(gameTypes, true, false);
|
||||
GameCreationOptions configured;
|
||||
configured.description = "Configured";
|
||||
configured.maxPlayers = 8;
|
||||
configured.onlyBuddies = true;
|
||||
configured.joinAsJudge = true;
|
||||
configured.gameTypeIds = {2};
|
||||
form.setOptions(configured);
|
||||
|
||||
form.resetToDefaults();
|
||||
|
||||
const GameCreationOptions options = form.options();
|
||||
EXPECT_TRUE(options.description.isEmpty());
|
||||
EXPECT_EQ(options.maxPlayers, 2);
|
||||
EXPECT_FALSE(options.onlyBuddies);
|
||||
EXPECT_TRUE(options.onlyRegistered);
|
||||
EXPECT_TRUE(options.spectatorsAllowed);
|
||||
EXPECT_EQ(options.startingLifeTotal, 20);
|
||||
EXPECT_FALSE(options.joinAsJudge);
|
||||
EXPECT_TRUE(options.gameTypeIds.isEmpty());
|
||||
}
|
||||
|
||||
TEST(CreateGameFormWidget, RememberSettingsFlagRoundTrips)
|
||||
{
|
||||
CreateGameFormWidget form(gameTypes, true, false);
|
||||
|
||||
EXPECT_FALSE(form.isRememberSettingsChecked());
|
||||
|
||||
form.setRememberSettingsChecked(true);
|
||||
|
||||
EXPECT_TRUE(form.isRememberSettingsChecked());
|
||||
}
|
||||
|
||||
TEST(CreateGameFormWidget, SelectedGameTypesSettingStringUsesTheLegacyEncoding)
|
||||
{
|
||||
CreateGameFormWidget form(gameTypes, true, false);
|
||||
GameCreationOptions configured;
|
||||
configured.gameTypeIds = {1};
|
||||
form.setOptions(configured);
|
||||
|
||||
EXPECT_EQ(form.selectedGameTypesSettingString(), "Draft, ");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
QApplication app(argc, argv);
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
|
@ -278,6 +278,37 @@ TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones)
|
|||
EXPECT_EQ(mapping[1].second, 1);
|
||||
}
|
||||
|
||||
TEST(DeckListModelCustomZones, ManaGroupsUseNumericOrderWithoutChangingOtherNodes)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
const QStringList original = {"10", "2", "0.5", "1000000", "1", "unknown", "nan"};
|
||||
for (const auto &name : original) {
|
||||
new InnerDecklistNode(name, board);
|
||||
}
|
||||
auto *custom = new DecklistModelSubZoneNode("1x", board);
|
||||
new InnerDecklistNode("10", custom);
|
||||
new InnerDecklistNode("2", custom);
|
||||
root.setSortMethod(DeckSortMethod::ByName);
|
||||
|
||||
const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder, true);
|
||||
EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "2", "10", "1000000", "nan", "unknown", "1x"}));
|
||||
QStringList before = original;
|
||||
before.append("1x");
|
||||
for (const auto &move : mapping) {
|
||||
EXPECT_EQ(board->at(move.second)->getName(), before.at(move.first));
|
||||
}
|
||||
|
||||
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder, true);
|
||||
EXPECT_EQ(childNames(board), (QStringList{"1000000", "10", "2", "1", "0.5", "unknown", "nan", "1x"}));
|
||||
|
||||
// The numeric flag only affects groups directly under a board.
|
||||
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, custom, Qt::AscendingOrder, true);
|
||||
EXPECT_EQ(childNames(custom), (QStringList{"10", "2"}));
|
||||
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder);
|
||||
EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "10", "1000000", "2", "nan", "unknown", "1x"}));
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
|
|
|
|||
|
|
@ -393,6 +393,28 @@ TEST(DeckListZones, MaybeboardCustomZoneCardsAreExcludedFromHash)
|
|||
EXPECT_EQ(deck.getDeckHash(), expected.getDeckHash());
|
||||
}
|
||||
|
||||
TEST(DeckListZones, PlainExportPutsMainFirstWithoutReorderingSavedZones)
|
||||
{
|
||||
DeckList deck;
|
||||
auto *tree = deck.getTree();
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr);
|
||||
tree->addCard("Side Card", 2, "Side Tech", -1);
|
||||
tree->addCard("Token Card", 1, DECK_ZONE_TOKENS, -1);
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
tree->addCard("Main Card", 3, "Removal", -1);
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr);
|
||||
tree->addCard("Maybe Card", 4, "Candidates", -1);
|
||||
|
||||
const QString native = deck.writeToString_Native();
|
||||
EXPECT_EQ(deck.writeToString_Plain(true, false), QString("3 Main Card\nSB: 2 Side Card\n1 Token Card\n"));
|
||||
EXPECT_EQ(deck.writeToString_Plain(false, false), QString("3 Main Card\n2 Side Card\n1 Token Card\n"));
|
||||
EXPECT_EQ(deck.writeToString_Native(), native);
|
||||
|
||||
DeckList reopened(native);
|
||||
EXPECT_EQ(reopened.getZoneNodes().first()->getName(), DECK_ZONE_SIDE);
|
||||
EXPECT_EQ(reopened.writeToString_Plain(true, false), deck.writeToString_Plain(true, false));
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
|
|
|
|||
190
tests/deck_state_manager_test.cpp
Normal file
190
tests/deck_state_manager_test.cpp
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/** @file deck_state_manager_test.cpp
|
||||
* @brief Tests for DeckStateManager operations.
|
||||
* @ingroup Tests
|
||||
*/
|
||||
|
||||
#include "libcockatrice/card/database/card_database.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QStandardPaths>
|
||||
#include <client/settings/cache_settings.h>
|
||||
#include <deck_list_model.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <interface/widgets/deck_editor/deck_state_manager.h>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/card/printing/exact_card.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Assert that the DeckStateManager's deck list contains only the given zone,
|
||||
* and that that zone only contains the given number of cards, all with the given card name.
|
||||
*
|
||||
* @param manager the DeckStateManager
|
||||
* @param restrictToZones In which zones to look for the zone and cards. Empty means use the entire deck
|
||||
* @param zone The only zone that should exist in the deck
|
||||
* @param cardName The name of the only card that should exist in the deck
|
||||
* @param cardNumber The number of cards that should be in the deck
|
||||
*/
|
||||
static void containsExactly(const DeckStateManager &manager,
|
||||
const QSet<QString> &restrictToZones,
|
||||
QString zone,
|
||||
QString cardName,
|
||||
int cardNumber)
|
||||
{
|
||||
QList<const InnerDecklistNode *> zones = manager.getDeckList().getZoneNodes(restrictToZones);
|
||||
ASSERT_EQ(zones.size(), 1);
|
||||
ASSERT_EQ(zones[0]->getName(), zone);
|
||||
ASSERT_EQ(zones[0]->size(), 1);
|
||||
const DecklistCardNode *card = dynamic_cast<const DecklistCardNode *>((*zones[0])[0]);
|
||||
ASSERT_NE(card, nullptr);
|
||||
ASSERT_EQ(card->getName(), cardName);
|
||||
ASSERT_EQ(card->getNumber(), cardNumber);
|
||||
}
|
||||
|
||||
/** @brief Return CardInfo of a token with the given name. */
|
||||
static CardInfoPtr makeToken(QString name)
|
||||
{
|
||||
return CardInfo::newInstance(name, "", true, {}, {}, {}, {}, {});
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
// addCard
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckStateManager, AddCards)
|
||||
{
|
||||
DeckStateManager manager;
|
||||
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
|
||||
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1);
|
||||
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
|
||||
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2);
|
||||
}
|
||||
|
||||
TEST(DeckStateManager, AddCardZones)
|
||||
{
|
||||
DeckStateManager manager;
|
||||
manager.addCard(ExactCard(CardInfo::newInstance("Grizzly Bears")), DECK_ZONE_MAIN);
|
||||
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE);
|
||||
manager.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), DECK_ZONE_MAYBEBOARD);
|
||||
|
||||
// Check if the cards got put into the correct zones.
|
||||
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 3);
|
||||
containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Grizzly Bears", 1);
|
||||
containsExactly(manager, {DECK_ZONE_SIDE}, DECK_ZONE_SIDE, "Lightning Bolt", 1);
|
||||
containsExactly(manager, {DECK_ZONE_MAYBEBOARD}, DECK_ZONE_MAYBEBOARD, "Swords to Plowshares", 1);
|
||||
}
|
||||
|
||||
TEST(DeckStateManager, AddTokens)
|
||||
{
|
||||
DeckStateManager manager;
|
||||
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
|
||||
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE);
|
||||
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAYBEBOARD);
|
||||
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS);
|
||||
|
||||
// A token should always be put into the tokens zone.
|
||||
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 4);
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
// decrementCard
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckStateManager, DecrementCards)
|
||||
{
|
||||
DeckStateManager manager;
|
||||
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
|
||||
manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
|
||||
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2);
|
||||
|
||||
// Removing from the sideboard should do nothing, as there is nothing in the side board.
|
||||
manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE);
|
||||
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2);
|
||||
|
||||
// Remove the first card.
|
||||
manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
|
||||
containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1);
|
||||
|
||||
// Removing the second card should also remove the zone.
|
||||
manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN);
|
||||
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0);
|
||||
}
|
||||
|
||||
TEST(DeckStateManager, DecrementTokens)
|
||||
{
|
||||
DeckStateManager manager;
|
||||
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
|
||||
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE);
|
||||
manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS);
|
||||
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 3);
|
||||
|
||||
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS);
|
||||
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 2);
|
||||
|
||||
// Removing tokens from a given zone should remove them from the
|
||||
// token zone if the given zone does not contain the token.
|
||||
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE);
|
||||
containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 1);
|
||||
|
||||
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
|
||||
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0);
|
||||
}
|
||||
|
||||
TEST(DeckStateManager, DecrementTokenMainboard)
|
||||
{
|
||||
DeckStateManager manager;
|
||||
|
||||
// Add cards to the deck list directly so tokens are not
|
||||
// automatically added to the token zone.
|
||||
manager.getDeckListShared()->addCard("Beast", DECK_ZONE_MAIN);
|
||||
manager.getDeckListShared()->addCard("Beast", DECK_ZONE_TOKENS);
|
||||
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 2);
|
||||
containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Beast", 1);
|
||||
containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1);
|
||||
|
||||
// decrementCard uses the model to find the card, so we need to rebuild it.
|
||||
manager.getModel()->rebuildTree();
|
||||
|
||||
// Tokens should be removed from the main zone first.
|
||||
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
|
||||
ASSERT_EQ(manager.getDeckList().getZoneNodes({DECK_ZONE_MAIN}).size(), 0);
|
||||
containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1);
|
||||
|
||||
// If the main zone is empty they can be removed from the token zone.
|
||||
manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN);
|
||||
ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
// Redirect SettingsCache reads/writes (app-data location) away from the real user profile.
|
||||
QStandardPaths::setTestModeEnabled(true);
|
||||
QCoreApplication app(argc, argv);
|
||||
QCoreApplication::setOrganizationName(QStringLiteral("CockatriceTest"));
|
||||
QCoreApplication::setApplicationName(
|
||||
QStringLiteral("deck-state-manager-test-%1").arg(QCoreApplication::applicationPid()));
|
||||
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
|
||||
// Add cards to the card database
|
||||
auto *db = CardDatabaseManager::getInstance();
|
||||
db->addCard(CardInfo::newInstance("Lightning Bolt"));
|
||||
db->addCard(CardInfo::newInstance("Grizzly Bears"));
|
||||
db->addCard(CardInfo::newInstance("Swords to Plowshares"));
|
||||
db->addCard(makeToken("Beast"));
|
||||
|
||||
int result = RUN_ALL_TESTS();
|
||||
|
||||
// Clean up the settings directory in ~/.qttest if the tests ran.
|
||||
if (!::testing::GTEST_FLAG(list_tests)) {
|
||||
QDir(SettingsCache::instance().getDataPath()).removeRecursively();
|
||||
QDir(SettingsCache::instance().getCachePath()).removeRecursively();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
195
tests/font_config_test.cpp
Normal file
195
tests/font_config_test.cpp
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
#include "interface/theme_config.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QFont>
|
||||
#include <QIODevice>
|
||||
#include <QString>
|
||||
#include <QStringLiteral>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTextStream>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
// Writes content to a file inside tmp, creating parents as needed.
|
||||
QString writeTempFile(QTemporaryDir &tmp, const QString &relativePath, const QString &content)
|
||||
{
|
||||
const QString filePath = QDir(tmp.path()).absoluteFilePath(relativePath);
|
||||
EXPECT_TRUE(QDir().mkpath(QFileInfo(filePath).absolutePath()));
|
||||
QFile file(filePath);
|
||||
EXPECT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
QTextStream out(&file);
|
||||
out << content;
|
||||
file.close();
|
||||
return filePath;
|
||||
}
|
||||
|
||||
QFont makeFont(const QString &family, int pointSize)
|
||||
{
|
||||
QFont font(family);
|
||||
font.setPointSize(pointSize);
|
||||
return font;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(FontConfigTest, FileNameIsFontsToml)
|
||||
{
|
||||
EXPECT_EQ(FontConfig::fileName(), QStringLiteral("fonts.toml"));
|
||||
}
|
||||
|
||||
TEST(FontConfigTest, EmptyByDefault)
|
||||
{
|
||||
FontConfig cfg;
|
||||
EXPECT_FALSE(cfg.hasFonts());
|
||||
EXPECT_TRUE(cfg.toToml().isEmpty());
|
||||
EXPECT_EQ(cfg, FontConfig());
|
||||
EXPECT_EQ(cfg, FontConfig::fromFile("/nonexistent/path/fonts.toml"));
|
||||
EXPECT_EQ(cfg, FontConfig::fromThemeDir(QString()));
|
||||
}
|
||||
|
||||
TEST(FontConfigTest, RoundTripPreservesFamilyAndSize)
|
||||
{
|
||||
QTemporaryDir tmp;
|
||||
ASSERT_TRUE(tmp.isValid());
|
||||
|
||||
FontConfig cfg;
|
||||
cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
|
||||
cfg.fonts[AppFont::ChatLog] = makeFont("JetBrains Mono", 9);
|
||||
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11);
|
||||
cfg.fonts[AppFont::DeckList] = makeFont("Roboto", 12);
|
||||
ASSERT_TRUE(cfg.hasFonts());
|
||||
|
||||
const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml());
|
||||
const FontConfig loaded = FontConfig::fromFile(filePath);
|
||||
|
||||
EXPECT_EQ(loaded, cfg);
|
||||
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].family(), QStringLiteral("Inter"));
|
||||
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].pointSize(), 10);
|
||||
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("JetBrains Mono"));
|
||||
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 9);
|
||||
EXPECT_EQ(loaded.fonts[AppFont::CardText].family(), QStringLiteral("Noto Serif"));
|
||||
EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 11);
|
||||
EXPECT_EQ(loaded.fonts[AppFont::DeckList].family(), QStringLiteral("Roboto"));
|
||||
EXPECT_EQ(loaded.fonts[AppFont::DeckList].pointSize(), 12);
|
||||
}
|
||||
|
||||
TEST(FontConfigTest, RoundTripStripsWeightAndItalic)
|
||||
{
|
||||
QTemporaryDir tmp;
|
||||
ASSERT_TRUE(tmp.isValid());
|
||||
|
||||
QFont styled("Inter");
|
||||
styled.setPointSize(10);
|
||||
styled.setBold(true);
|
||||
styled.setItalic(true);
|
||||
|
||||
FontConfig cfg;
|
||||
cfg.fonts[AppFont::ChatLog] = styled;
|
||||
|
||||
const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml());
|
||||
const FontConfig loaded = FontConfig::fromFile(filePath);
|
||||
|
||||
ASSERT_TRUE(loaded.fonts.contains(AppFont::ChatLog));
|
||||
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("Inter"));
|
||||
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 10);
|
||||
EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].bold());
|
||||
EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].italic());
|
||||
EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("Inter", 10));
|
||||
}
|
||||
|
||||
TEST(FontConfigTest, ToTomlFormat)
|
||||
{
|
||||
FontConfig cfg;
|
||||
cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
|
||||
|
||||
const QString toml = cfg.toToml();
|
||||
EXPECT_TRUE(toml.contains(QStringLiteral("[Fonts]")));
|
||||
EXPECT_TRUE(toml.contains(QStringLiteral("InterfaceDefault")));
|
||||
EXPECT_TRUE(toml.contains(QStringLiteral("\"Inter, 10\"")));
|
||||
}
|
||||
|
||||
TEST(FontConfigTest, MalformedLinesAreSkipped)
|
||||
{
|
||||
QTemporaryDir tmp;
|
||||
ASSERT_TRUE(tmp.isValid());
|
||||
|
||||
const QString filePath = writeTempFile(tmp, "fonts.toml",
|
||||
"# a comment\n"
|
||||
"; another comment\n"
|
||||
"\n"
|
||||
"StrayBeforeSection = \"Inter, 10\"\n"
|
||||
"\n"
|
||||
"[Other]\n"
|
||||
"ChatLog = \"ShouldBeIgnored, 10\"\n"
|
||||
"\n"
|
||||
"[Fonts]\n"
|
||||
"InterfaceDefault = \"Inter, 10\"\n"
|
||||
"ChatLog = \"JetBrains Mono\", 9 # inline comment\n"
|
||||
"CardText = Noto Serif, 11\n"
|
||||
"AppFont::DeckList = \"Roboto, 12\"\n"
|
||||
"UnknownRole = \"Inter, 10\"\n"
|
||||
"QPalette::Window = \"Inter, 10\"\n"
|
||||
"NoComma = \"Inter\"\n"
|
||||
"EmptyFamily = \", 10\"\n"
|
||||
"ZeroSize = \"Inter, 0\"\n"
|
||||
"NegativeSize = \"Inter, -3\"\n"
|
||||
"NotANumber = \"Inter, big\"\n"
|
||||
"NotAnEntry\n");
|
||||
|
||||
const FontConfig loaded = FontConfig::fromFile(filePath);
|
||||
|
||||
ASSERT_TRUE(loaded.hasFonts());
|
||||
EXPECT_EQ(loaded.fonts.size(), 4);
|
||||
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault], makeFont("Inter", 10));
|
||||
EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("JetBrains Mono", 9));
|
||||
EXPECT_EQ(loaded.fonts[AppFont::CardText], makeFont("Noto Serif", 11));
|
||||
EXPECT_EQ(loaded.fonts[AppFont::DeckList], makeFont("Roboto", 12));
|
||||
}
|
||||
|
||||
TEST(FontConfigTest, FromThemeDirReadsFontsToml)
|
||||
{
|
||||
QTemporaryDir tmp;
|
||||
ASSERT_TRUE(tmp.isValid());
|
||||
|
||||
const QString themeDir = QDir(tmp.path()).absoluteFilePath("MyTheme");
|
||||
ASSERT_TRUE(QDir().mkpath(themeDir));
|
||||
|
||||
FontConfig cfg;
|
||||
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11);
|
||||
writeTempFile(tmp, "MyTheme/fonts.toml", cfg.toToml());
|
||||
|
||||
const FontConfig loaded = FontConfig::fromThemeDir(themeDir);
|
||||
EXPECT_EQ(loaded, cfg);
|
||||
|
||||
// A theme directory without fonts.toml loads as empty.
|
||||
const QString bareDir = QDir(tmp.path()).absoluteFilePath("BareTheme");
|
||||
ASSERT_TRUE(QDir().mkpath(bareDir));
|
||||
EXPECT_FALSE(FontConfig::fromThemeDir(bareDir).hasFonts());
|
||||
}
|
||||
|
||||
TEST(FontConfigTest, InequalityComparesRoles)
|
||||
{
|
||||
FontConfig left;
|
||||
left.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
|
||||
|
||||
FontConfig right = left;
|
||||
EXPECT_EQ(left, right);
|
||||
EXPECT_FALSE(left != right);
|
||||
|
||||
right.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 11);
|
||||
EXPECT_NE(left, right);
|
||||
|
||||
right = left;
|
||||
right.fonts[AppFont::ChatLog] = makeFont("Inter", 10);
|
||||
EXPECT_NE(left, right);
|
||||
}
|
||||
|
||||
// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and
|
||||
// the fallback then links the bare gtest library without gtest_main.
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
#include <libcockatrice/game/zones/hand_zone_logic.h>
|
||||
#include <libcockatrice/game/zones/view_zone_logic.h>
|
||||
#include <libcockatrice/utility/card_ref.h>
|
||||
#include <libcockatrice/utility/zone_names.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
|
@ -105,3 +106,30 @@ TEST(CardZoneLogicTest, ReorganizeSignalFiresOnAdd)
|
|||
|
||||
EXPECT_EQ(reorganizeSpy.count(), 1);
|
||||
}
|
||||
|
||||
TEST(CardZoneLogicTest, LibraryEdgeViewIsLimitedToOneEndOfTheLibrary)
|
||||
{
|
||||
HandZoneLogic deck(nullptr, QString::fromLatin1(ZoneNames::DECK), false, true, true);
|
||||
HandZoneLogic grave(nullptr, QString::fromLatin1(ZoneNames::GRAVE), false, true, true);
|
||||
|
||||
ZoneViewZoneLogic fullDeckView(nullptr, &deck, -1, false, false, false);
|
||||
EXPECT_FALSE(fullDeckView.isLibraryEdgeView());
|
||||
EXPECT_FALSE(fullDeckView.showsTopOfLibrary());
|
||||
EXPECT_FALSE(fullDeckView.showsBottomOfLibrary());
|
||||
|
||||
ZoneViewZoneLogic emptyDeckView(nullptr, &deck, 0, false, false, false);
|
||||
EXPECT_FALSE(emptyDeckView.isLibraryEdgeView());
|
||||
|
||||
ZoneViewZoneLogic topView(nullptr, &deck, 3, false, false, false);
|
||||
EXPECT_TRUE(topView.isLibraryEdgeView());
|
||||
EXPECT_TRUE(topView.showsTopOfLibrary());
|
||||
EXPECT_FALSE(topView.showsBottomOfLibrary());
|
||||
|
||||
ZoneViewZoneLogic bottomView(nullptr, &deck, 3, false, false, true);
|
||||
EXPECT_TRUE(bottomView.isLibraryEdgeView());
|
||||
EXPECT_FALSE(bottomView.showsTopOfLibrary());
|
||||
EXPECT_TRUE(bottomView.showsBottomOfLibrary());
|
||||
|
||||
ZoneViewZoneLogic graveView(nullptr, &grave, 3, false, false, false);
|
||||
EXPECT_FALSE(graveView.isLibraryEdgeView());
|
||||
}
|
||||
|
|
|
|||
310
tests/game_creation_options_test.cpp
Normal file
310
tests/game_creation_options_test.cpp
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
#include "interface/widgets/game_hosting/game_creation_options.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/interfaces/interface_game_settings_provider.h>
|
||||
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
/**
|
||||
* @brief The remembered settings a test hands to fromSettings.
|
||||
*
|
||||
* The fake cannot itself be an aggregate (it derives from a polymorphic interface), so the
|
||||
* remembered values live in a GameCreationOptions member and the getters read them from
|
||||
* there. That keeps the test data in one designated initializer.
|
||||
*/
|
||||
class FakeGameSettings : public IGameSettingsProvider
|
||||
{
|
||||
public:
|
||||
explicit FakeGameSettings(const GameCreationOptions &_options = {}) : options(_options)
|
||||
{
|
||||
}
|
||||
|
||||
[[nodiscard]] QString getGameDescription() const override
|
||||
{
|
||||
return options.description;
|
||||
}
|
||||
[[nodiscard]] int getMaxPlayers() const override
|
||||
{
|
||||
return options.maxPlayers;
|
||||
}
|
||||
[[nodiscard]] QString getGameTypes() const override
|
||||
{
|
||||
return gameTypes;
|
||||
}
|
||||
[[nodiscard]] bool getOnlyBuddies() const override
|
||||
{
|
||||
return options.onlyBuddies;
|
||||
}
|
||||
[[nodiscard]] bool getOnlyRegistered() const override
|
||||
{
|
||||
return options.onlyRegistered;
|
||||
}
|
||||
[[nodiscard]] bool getSpectatorsAllowed() const override
|
||||
{
|
||||
return options.spectatorsAllowed;
|
||||
}
|
||||
[[nodiscard]] bool getSpectatorsNeedPassword() const override
|
||||
{
|
||||
return options.spectatorsNeedPassword;
|
||||
}
|
||||
[[nodiscard]] bool getSpectatorsCanTalk() const override
|
||||
{
|
||||
return options.spectatorsCanTalk;
|
||||
}
|
||||
[[nodiscard]] bool getSpectatorsCanSeeEverything() const override
|
||||
{
|
||||
return options.spectatorsSeeEverything;
|
||||
}
|
||||
[[nodiscard]] bool getCreateGameAsSpectator() const override
|
||||
{
|
||||
return options.joinAsSpectator;
|
||||
}
|
||||
[[nodiscard]] int getDefaultStartingLifeTotal() const override
|
||||
{
|
||||
return options.startingLifeTotal;
|
||||
}
|
||||
[[nodiscard]] bool getShareDecklistsOnLoad() const override
|
||||
{
|
||||
return options.shareDecklistsOnLoad;
|
||||
}
|
||||
[[nodiscard]] bool getShufflePlayers() const override
|
||||
{
|
||||
return options.shufflePlayers;
|
||||
}
|
||||
[[nodiscard]] bool getRememberGameSettings() const override
|
||||
{
|
||||
return false;
|
||||
}
|
||||
[[nodiscard]] bool getLocalGameRememberSettings() const override
|
||||
{
|
||||
return false;
|
||||
}
|
||||
[[nodiscard]] int getLocalGameMaxPlayers() const override
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
[[nodiscard]] int getLocalGameStartingLifeTotal() const override
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
GameCreationOptions options;
|
||||
/// The legacy "Name, " list; it has no GameCreationOptions counterpart.
|
||||
QString gameTypes;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST(GameCreationOptions, DefaultsMatchTheDialog)
|
||||
{
|
||||
const GameCreationOptions options;
|
||||
|
||||
EXPECT_TRUE(options.description.isEmpty());
|
||||
EXPECT_TRUE(options.password.isEmpty());
|
||||
EXPECT_EQ(options.maxPlayers, 2);
|
||||
EXPECT_FALSE(options.onlyBuddies);
|
||||
EXPECT_FALSE(options.onlyRegistered);
|
||||
EXPECT_TRUE(options.spectatorsAllowed);
|
||||
EXPECT_FALSE(options.spectatorsNeedPassword);
|
||||
EXPECT_FALSE(options.spectatorsCanTalk);
|
||||
EXPECT_FALSE(options.spectatorsSeeEverything);
|
||||
EXPECT_FALSE(options.joinAsJudge);
|
||||
EXPECT_FALSE(options.joinAsSpectator);
|
||||
EXPECT_EQ(options.startingLifeTotal, 20);
|
||||
EXPECT_FALSE(options.shareDecklistsOnLoad);
|
||||
EXPECT_FALSE(options.shufflePlayers);
|
||||
EXPECT_TRUE(options.gameTypeIds.isEmpty());
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, EveryFieldMapsToTheCommand)
|
||||
{
|
||||
GameCreationOptions options;
|
||||
options.description = "Some game";
|
||||
options.password = "hunter2";
|
||||
options.maxPlayers = 4;
|
||||
options.onlyBuddies = true;
|
||||
options.onlyRegistered = true;
|
||||
options.spectatorsAllowed = false;
|
||||
options.spectatorsNeedPassword = true;
|
||||
options.spectatorsCanTalk = true;
|
||||
options.spectatorsSeeEverything = true;
|
||||
options.joinAsJudge = true;
|
||||
options.joinAsSpectator = true;
|
||||
options.startingLifeTotal = 40;
|
||||
options.shareDecklistsOnLoad = true;
|
||||
options.shufflePlayers = true;
|
||||
|
||||
const Command_CreateGame cmd = options.toCreateGameCommand();
|
||||
|
||||
EXPECT_EQ(cmd.description(), "Some game");
|
||||
EXPECT_EQ(cmd.password(), "hunter2");
|
||||
EXPECT_EQ(cmd.max_players(), 4);
|
||||
EXPECT_TRUE(cmd.only_buddies());
|
||||
EXPECT_TRUE(cmd.only_registered());
|
||||
EXPECT_FALSE(cmd.spectators_allowed());
|
||||
EXPECT_TRUE(cmd.spectators_need_password());
|
||||
EXPECT_TRUE(cmd.spectators_can_talk());
|
||||
EXPECT_TRUE(cmd.spectators_see_everything());
|
||||
EXPECT_TRUE(cmd.join_as_judge());
|
||||
EXPECT_TRUE(cmd.join_as_spectator());
|
||||
EXPECT_EQ(cmd.starting_life_total(), 40);
|
||||
EXPECT_TRUE(cmd.share_decklists_on_load());
|
||||
EXPECT_TRUE(cmd.shuffle_players());
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, GameTypeIdsKeepTheirOrder)
|
||||
{
|
||||
GameCreationOptions options;
|
||||
options.gameTypeIds = {7, 2, 5};
|
||||
|
||||
const Command_CreateGame cmd = options.toCreateGameCommand();
|
||||
|
||||
ASSERT_EQ(cmd.game_type_ids_size(), 3);
|
||||
EXPECT_EQ(cmd.game_type_ids(0), 7);
|
||||
EXPECT_EQ(cmd.game_type_ids(1), 2);
|
||||
EXPECT_EQ(cmd.game_type_ids(2), 5);
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, EmptyGameTypeIdsProduceAnEmptyRepeatedField)
|
||||
{
|
||||
GameCreationOptions options;
|
||||
|
||||
const Command_CreateGame cmd = options.toCreateGameCommand();
|
||||
|
||||
EXPECT_EQ(cmd.game_type_ids_size(), 0);
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, DescriptionWhitespaceIsSimplified)
|
||||
{
|
||||
GameCreationOptions options;
|
||||
options.description = " a b ";
|
||||
|
||||
const Command_CreateGame cmd = options.toCreateGameCommand();
|
||||
|
||||
EXPECT_EQ(cmd.description(), "a b");
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, FromSettingsCopiesEveryRememberedField)
|
||||
{
|
||||
const FakeGameSettings settings{GameCreationOptions{.description = "Remembered game",
|
||||
.password = {},
|
||||
.maxPlayers = 6,
|
||||
.onlyBuddies = true,
|
||||
.onlyRegistered = true,
|
||||
.spectatorsAllowed = false,
|
||||
.spectatorsNeedPassword = true,
|
||||
.spectatorsCanTalk = true,
|
||||
.spectatorsSeeEverything = true,
|
||||
.joinAsJudge = false,
|
||||
.joinAsSpectator = true,
|
||||
.startingLifeTotal = 40,
|
||||
.shareDecklistsOnLoad = true,
|
||||
.shufflePlayers = true,
|
||||
.gameTypeIds = {}}};
|
||||
|
||||
const GameCreationOptions options = GameCreationOptions::fromSettings(settings, {}, true);
|
||||
|
||||
EXPECT_EQ(options.description, "Remembered game");
|
||||
EXPECT_EQ(options.maxPlayers, 6);
|
||||
EXPECT_TRUE(options.onlyBuddies);
|
||||
EXPECT_TRUE(options.onlyRegistered);
|
||||
EXPECT_FALSE(options.spectatorsAllowed);
|
||||
EXPECT_TRUE(options.spectatorsNeedPassword);
|
||||
EXPECT_TRUE(options.spectatorsCanTalk);
|
||||
EXPECT_TRUE(options.spectatorsSeeEverything);
|
||||
EXPECT_TRUE(options.joinAsSpectator);
|
||||
EXPECT_EQ(options.startingLifeTotal, 40);
|
||||
EXPECT_TRUE(options.shareDecklistsOnLoad);
|
||||
EXPECT_TRUE(options.shufflePlayers);
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, FromSettingsDropsJoinRestrictionsForUnregisteredHosts)
|
||||
{
|
||||
FakeGameSettings settings;
|
||||
settings.options.onlyBuddies = true;
|
||||
settings.options.onlyRegistered = true;
|
||||
|
||||
const GameCreationOptions options = GameCreationOptions::fromSettings(settings, {}, false);
|
||||
|
||||
EXPECT_FALSE(options.onlyBuddies);
|
||||
EXPECT_FALSE(options.onlyRegistered);
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, FromSettingsMatchesGameTypesByTheLegacyNameList)
|
||||
{
|
||||
FakeGameSettings settings;
|
||||
settings.gameTypes = "Draft, Commander, ";
|
||||
const QMap<int, QString> gameTypes{{1, "Draft"}, {2, "Commander"}, {3, "Standard"}};
|
||||
|
||||
const GameCreationOptions options = GameCreationOptions::fromSettings(settings, gameTypes, true);
|
||||
|
||||
EXPECT_EQ(options.gameTypeIds, QList<int>({1, 2}));
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, FromSettingsDoesNotMatchGameTypesWithoutTheLegacySeparator)
|
||||
{
|
||||
FakeGameSettings settings;
|
||||
settings.gameTypes = "Draft Commander";
|
||||
const QMap<int, QString> gameTypes{{1, "Draft"}, {2, "Commander"}};
|
||||
|
||||
const GameCreationOptions options = GameCreationOptions::fromSettings(settings, gameTypes, true);
|
||||
|
||||
EXPECT_TRUE(options.gameTypeIds.isEmpty());
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, FromGameInfoCopiesTheVisibleFields)
|
||||
{
|
||||
ServerInfo_Game gameInfo;
|
||||
gameInfo.set_description("Some game");
|
||||
gameInfo.set_max_players(4);
|
||||
gameInfo.set_only_buddies(true);
|
||||
gameInfo.set_only_registered(true);
|
||||
gameInfo.set_spectators_allowed(false);
|
||||
gameInfo.set_spectators_need_password(true);
|
||||
gameInfo.set_spectators_can_chat(true);
|
||||
gameInfo.set_spectators_omniscient(true);
|
||||
gameInfo.set_share_decklists_on_load(true);
|
||||
gameInfo.set_shuffle_players(true);
|
||||
gameInfo.add_game_types(2);
|
||||
|
||||
const QMap<int, QString> gameTypes{{1, "Draft"}, {2, "Commander"}};
|
||||
const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, gameTypes);
|
||||
|
||||
EXPECT_EQ(options.description, "Some game");
|
||||
EXPECT_EQ(options.maxPlayers, 4);
|
||||
EXPECT_TRUE(options.onlyBuddies);
|
||||
EXPECT_TRUE(options.onlyRegistered);
|
||||
EXPECT_FALSE(options.spectatorsAllowed);
|
||||
EXPECT_TRUE(options.spectatorsNeedPassword);
|
||||
EXPECT_TRUE(options.spectatorsCanTalk);
|
||||
EXPECT_TRUE(options.spectatorsSeeEverything);
|
||||
EXPECT_TRUE(options.shareDecklistsOnLoad);
|
||||
EXPECT_TRUE(options.shufflePlayers);
|
||||
EXPECT_EQ(options.gameTypeIds, QList<int>({2}));
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, FromGameInfoLeavesUnshownFieldsAtDefaults)
|
||||
{
|
||||
ServerInfo_Game gameInfo;
|
||||
gameInfo.set_max_players(3);
|
||||
|
||||
const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, {});
|
||||
|
||||
EXPECT_TRUE(options.password.isEmpty());
|
||||
EXPECT_EQ(options.startingLifeTotal, 20);
|
||||
EXPECT_FALSE(options.joinAsJudge);
|
||||
EXPECT_FALSE(options.joinAsSpectator);
|
||||
}
|
||||
|
||||
TEST(GameCreationOptions, FromGameInfoIgnoresUnknownGameTypeIds)
|
||||
{
|
||||
ServerInfo_Game gameInfo;
|
||||
gameInfo.add_game_types(7);
|
||||
gameInfo.add_game_types(9);
|
||||
|
||||
const QMap<int, QString> gameTypes{{1, "Draft"}, {7, "Commander"}};
|
||||
const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, gameTypes);
|
||||
|
||||
EXPECT_EQ(options.gameTypeIds, QList<int>({7}));
|
||||
}
|
||||
3
tests/icon_assets/CMakeLists.txt
Normal file
3
tests/icon_assets/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
cockatrice_add_test(
|
||||
NAME icon_assets_test SOURCES icon_assets_test.cpp DEFINITIONS COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
)
|
||||
241
tests/icon_assets/icon_assets_test.cpp
Normal file
241
tests/icon_assets/icon_assets_test.cpp
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
#include <QColor>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QXmlStreamReader>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
const QString ICONS_RELATIVE_PATH = QStringLiteral("cockatrice/resources/icons");
|
||||
|
||||
QString iconsDir()
|
||||
{
|
||||
return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(ICONS_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
/** @brief True for content art that is allowed to carry full color. */
|
||||
bool isContentArt(const QString &relativePath)
|
||||
{
|
||||
return relativePath.startsWith(QStringLiteral("mana/"));
|
||||
}
|
||||
|
||||
QString violation(const QString &rule, const QString &relativePath)
|
||||
{
|
||||
return rule + QLatin1Char(' ') + relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Reads one SVG and returns true when the root carries a usable viewBox.
|
||||
*
|
||||
* Also reports malformed XML and editor-only metadata through the out params.
|
||||
*/
|
||||
bool inspectSvg(const QString &absolutePath,
|
||||
bool &validXml,
|
||||
bool &hasViewBox,
|
||||
bool &hasEditorMetadata,
|
||||
bool &hasPhysicalSize)
|
||||
{
|
||||
validXml = false;
|
||||
hasViewBox = false;
|
||||
hasEditorMetadata = false;
|
||||
hasPhysicalSize = false;
|
||||
|
||||
QFile file(absolutePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QXmlStreamReader reader(&file);
|
||||
bool sawRoot = false;
|
||||
|
||||
while (!reader.atEnd()) {
|
||||
reader.readNext();
|
||||
|
||||
if (reader.hasError()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!reader.isStartElement()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sawRoot) {
|
||||
sawRoot = true;
|
||||
if (reader.name() != QStringLiteral("svg")) {
|
||||
return false;
|
||||
}
|
||||
const QString viewBox = reader.attributes().value(QStringLiteral("viewBox")).toString().simplified();
|
||||
hasViewBox = viewBox == QStringLiteral("0 0 24 24");
|
||||
hasPhysicalSize = !reader.attributes().value(QStringLiteral("width")).isEmpty() ||
|
||||
!reader.attributes().value(QStringLiteral("height")).isEmpty();
|
||||
}
|
||||
|
||||
if (reader.name() == QStringLiteral("metadata") || reader.name() == QStringLiteral("namedview")) {
|
||||
hasEditorMetadata = true;
|
||||
}
|
||||
|
||||
for (const QXmlStreamAttribute &attribute : reader.attributes()) {
|
||||
const QString prefix = attribute.prefix().toString();
|
||||
if (prefix == QStringLiteral("inkscape") || prefix == QStringLiteral("sodipodi") ||
|
||||
prefix == QStringLiteral("dc") || prefix == QStringLiteral("cc") || prefix == QStringLiteral("rdf")) {
|
||||
hasEditorMetadata = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validXml = sawRoot && !reader.hasError();
|
||||
return validXml;
|
||||
}
|
||||
|
||||
bool isAllowedPaintValue(const QString &value)
|
||||
{
|
||||
const QString normalized = value.trimmed().toLower();
|
||||
return normalized.isEmpty() || normalized == QStringLiteral("none") ||
|
||||
normalized == QStringLiteral("currentcolor") || normalized == QStringLiteral("inherit") ||
|
||||
normalized == QStringLiteral("context-fill") || normalized == QStringLiteral("context-stroke") ||
|
||||
normalized.startsWith(QStringLiteral("url("));
|
||||
}
|
||||
|
||||
QRegularExpression paintValuePattern()
|
||||
{
|
||||
static const QRegularExpression pattern(
|
||||
QStringLiteral(R"re((?:fill|stroke|stop-color|color)\s*[:=]\s*["']?\s*([^"'\s;>]+))re"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
return pattern;
|
||||
}
|
||||
|
||||
bool hasHardcodedColor(const QString &absolutePath)
|
||||
{
|
||||
QFile file(absolutePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString content = QString::fromUtf8(file.readAll());
|
||||
auto matches = paintValuePattern().globalMatch(content);
|
||||
while (matches.hasNext()) {
|
||||
const QString value = matches.next().captured(1).trimmed();
|
||||
if (isAllowedPaintValue(value)) {
|
||||
continue;
|
||||
}
|
||||
const QString normalized = value.toLower();
|
||||
if (normalized.startsWith(QLatin1Char('#')) || normalized.contains(QStringLiteral("rgb(")) ||
|
||||
normalized.contains(QStringLiteral("hsl(")) || QColor(value).isValid()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasCurrentColorToken(const QString &absolutePath)
|
||||
{
|
||||
QFile file(absolutePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString content = QString::fromUtf8(file.readAll());
|
||||
auto matches = paintValuePattern().globalMatch(content);
|
||||
while (matches.hasNext()) {
|
||||
if (matches.next().captured(1).trimmed().compare(QStringLiteral("currentColor"), Qt::CaseInsensitive) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isSchemeVariant(const QString &fileName)
|
||||
{
|
||||
return fileName.contains(QStringLiteral("-dark."), Qt::CaseInsensitive) ||
|
||||
fileName.contains(QStringLiteral("-light."), Qt::CaseInsensitive);
|
||||
}
|
||||
|
||||
bool hasColorInFilename(const QString &fileName)
|
||||
{
|
||||
static const QRegularExpression colorSuffix(
|
||||
QStringLiteral(R"(_(green|blue|red|yellow|black|white|gray|grey)\.svg$)"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
return colorSuffix.match(fileName).hasMatch();
|
||||
}
|
||||
|
||||
/** @brief Collects every current violation as "rule relative/path.svg". */
|
||||
QSet<QString> collectViolations()
|
||||
{
|
||||
QSet<QString> violations;
|
||||
|
||||
QDirIterator it(iconsDir(), QDir::Files, QDirIterator::Subdirectories);
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
const QFileInfo info = it.fileInfo();
|
||||
const QString relativePath = QDir(iconsDir()).relativeFilePath(info.absoluteFilePath());
|
||||
if (isContentArt(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
if (info.suffix().compare(QStringLiteral("svg"), Qt::CaseInsensitive) != 0) {
|
||||
violations.insert(violation(QStringLiteral("raster"), relativePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
bool validXml = false;
|
||||
bool hasViewBox = false;
|
||||
bool hasEditorMetadata = false;
|
||||
bool hasPhysicalSize = false;
|
||||
inspectSvg(info.absoluteFilePath(), validXml, hasViewBox, hasEditorMetadata, hasPhysicalSize);
|
||||
|
||||
if (!validXml) {
|
||||
violations.insert(violation(QStringLiteral("xml"), relativePath));
|
||||
}
|
||||
if (validXml && !hasViewBox) {
|
||||
violations.insert(violation(QStringLiteral("viewbox"), relativePath));
|
||||
}
|
||||
if (hasPhysicalSize) {
|
||||
violations.insert(violation(QStringLiteral("physical-size"), relativePath));
|
||||
}
|
||||
if (hasEditorMetadata) {
|
||||
violations.insert(violation(QStringLiteral("editor-metadata"), relativePath));
|
||||
}
|
||||
if (hasHardcodedColor(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) {
|
||||
violations.insert(violation(QStringLiteral("hardcoded-color"), relativePath));
|
||||
}
|
||||
if (!hasCurrentColorToken(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) {
|
||||
violations.insert(violation(QStringLiteral("no-token"), relativePath));
|
||||
}
|
||||
if (hasColorInFilename(info.fileName())) {
|
||||
violations.insert(violation(QStringLiteral("color-filename"), relativePath));
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
QString formatSet(const QSet<QString> &entries)
|
||||
{
|
||||
QStringList lines(entries.begin(), entries.end());
|
||||
lines.sort();
|
||||
return lines.join(QLatin1Char('\n'));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief Every UI glyph follows the palette-token rules.
|
||||
*
|
||||
* The legacy set was paid down icon by icon, so no baseline grandfathers
|
||||
* violations: a new violation fails right here. Scheme variants
|
||||
* (`*-dark.svg`/`*-light.svg`) and mana art are exempt inside collectViolations().
|
||||
*/
|
||||
TEST(IconAssets, AssetsFollowTheRules)
|
||||
{
|
||||
ASSERT_TRUE(QDir::isAbsolutePath(iconsDir()));
|
||||
ASSERT_TRUE(QDir(iconsDir()).exists()) << "Missing icon directory: " << iconsDir().toStdString();
|
||||
|
||||
const QSet<QString> violations = collectViolations();
|
||||
EXPECT_TRUE(violations.isEmpty()) << "Icon rule violations. Fix the icon; do not add exceptions:\n"
|
||||
<< formatSet(violations).toStdString();
|
||||
}
|
||||
82
tests/image_redirect_chain_test.cpp
Normal file
82
tests/image_redirect_chain_test.cpp
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
#include "../cockatrice/src/interface/card_picture_loader/redirect_chain.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
using RedirectCache = QHash<QUrl, QPair<QUrl, QDateTime>>;
|
||||
|
||||
QUrl url(int index)
|
||||
{
|
||||
return QUrl(QStringLiteral("https://cards.example/%1").arg(index));
|
||||
}
|
||||
|
||||
RedirectCache chain(int hops)
|
||||
{
|
||||
RedirectCache cache;
|
||||
for (int index = 0; index < hops; ++index) {
|
||||
cache.insert(url(index), {url(index + 1), QDateTime()});
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(ImageRedirectChain, MissingHopReturnsLastKnownUrlWithoutRemovingMappings)
|
||||
{
|
||||
auto cache = chain(2);
|
||||
const auto original = cache;
|
||||
EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(2));
|
||||
EXPECT_EQ(cache, original);
|
||||
EXPECT_EQ(resolveImageRedirects(url(9), cache, 10), url(9));
|
||||
}
|
||||
|
||||
TEST(ImageRedirectChain, ExactlyMaxHopsResolvesAndKeepsCache)
|
||||
{
|
||||
auto cache = chain(10);
|
||||
const auto original = cache;
|
||||
EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(10));
|
||||
EXPECT_EQ(cache, original);
|
||||
}
|
||||
|
||||
TEST(ImageRedirectChain, CycleReturnsOriginalAndPurgesVisitedMappings)
|
||||
{
|
||||
auto cache = chain(2);
|
||||
cache.insert(url(2), {url(1), QDateTime()});
|
||||
cache.insert(url(9), {url(10), QDateTime()});
|
||||
EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(0));
|
||||
EXPECT_FALSE(cache.contains(url(0)));
|
||||
EXPECT_FALSE(cache.contains(url(1)));
|
||||
EXPECT_FALSE(cache.contains(url(2)));
|
||||
EXPECT_TRUE(cache.contains(url(9)));
|
||||
}
|
||||
|
||||
TEST(ImageRedirectChain, OverlongChainReturnsOriginalAndPurgesVisitedMappings)
|
||||
{
|
||||
auto cache = chain(12);
|
||||
cache.insert(url(20), {url(21), QDateTime()});
|
||||
EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(0));
|
||||
for (int index = 0; index < 10; ++index) {
|
||||
EXPECT_FALSE(cache.contains(url(index)));
|
||||
}
|
||||
EXPECT_TRUE(cache.contains(url(10)));
|
||||
EXPECT_TRUE(cache.contains(url(11)));
|
||||
EXPECT_TRUE(cache.contains(url(20)));
|
||||
}
|
||||
|
||||
TEST(ImageRedirectChain, EmptyTargetStopsAtItsSource)
|
||||
{
|
||||
auto cache = chain(1);
|
||||
cache.insert(url(1), {QUrl(), QDateTime()});
|
||||
const auto original = cache;
|
||||
EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(1));
|
||||
EXPECT_EQ(cache, original);
|
||||
}
|
||||
|
||||
TEST(ImageRedirectChain, ZeroHopsDoesNotFollowOrRemoveMappings)
|
||||
{
|
||||
auto cache = chain(1);
|
||||
EXPECT_EQ(resolveImageRedirects(url(5), cache, 0), url(5));
|
||||
EXPECT_TRUE(cache.contains(url(0)));
|
||||
EXPECT_EQ(resolveImageRedirects(url(0), cache, 0), url(0));
|
||||
EXPECT_TRUE(cache.contains(url(0)));
|
||||
}
|
||||
85
tests/image_request_queue_test.cpp
Normal file
85
tests/image_request_queue_test.cpp
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#include "../cockatrice/src/interface/card_picture_loader/card_picture_loader_request_queue.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QQueue>
|
||||
#include <QUrl>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
using TestRequest = QPair<QUrl, QPointer<QObject>>;
|
||||
|
||||
QUrl url(int index)
|
||||
{
|
||||
return QUrl(QStringLiteral("https://cards.example/%1").arg(index));
|
||||
}
|
||||
|
||||
TestRequest live(int index, QObject &owner)
|
||||
{
|
||||
return qMakePair(url(index), QPointer<QObject>(&owner));
|
||||
}
|
||||
|
||||
TestRequest cancelled(int index)
|
||||
{
|
||||
return qMakePair(url(index), QPointer<QObject>());
|
||||
}
|
||||
|
||||
QList<QUrl> queuedUrls(const QQueue<TestRequest> &queue)
|
||||
{
|
||||
QList<QUrl> urls;
|
||||
for (const auto &entry : queue) {
|
||||
urls.append(entry.first);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST(PictureLoaderRequestQueue, EmptyQueueHasNothingToRetire)
|
||||
{
|
||||
QQueue<TestRequest> queue;
|
||||
EXPECT_TRUE(takeCancelledRequests(queue).isEmpty());
|
||||
EXPECT_TRUE(queue.isEmpty());
|
||||
}
|
||||
|
||||
TEST(PictureLoaderRequestQueue, LiveRequestsStayQueuedInOrder)
|
||||
{
|
||||
QObject first;
|
||||
QObject second;
|
||||
QQueue<TestRequest> queue;
|
||||
queue.enqueue(live(1, first));
|
||||
queue.enqueue(live(2, second));
|
||||
|
||||
EXPECT_TRUE(takeCancelledRequests(queue).isEmpty());
|
||||
EXPECT_EQ(queuedUrls(queue), (QList<QUrl>{url(1), url(2)}));
|
||||
}
|
||||
|
||||
TEST(PictureLoaderRequestQueue, CancelledRequestsAreRetiredInQueueOrder)
|
||||
{
|
||||
QObject owner;
|
||||
QQueue<TestRequest> queue;
|
||||
queue.enqueue(live(1, owner));
|
||||
queue.enqueue(cancelled(2));
|
||||
queue.enqueue(live(3, owner));
|
||||
queue.enqueue(cancelled(4));
|
||||
queue.enqueue(cancelled(5));
|
||||
|
||||
// Exactly one status entry is retired per vanished work item, in queue order.
|
||||
EXPECT_EQ(takeCancelledRequests(queue), (QList<QUrl>{url(2), url(4), url(5)}));
|
||||
// The surviving requests keep their relative order.
|
||||
EXPECT_EQ(queuedUrls(queue), (QList<QUrl>{url(1), url(3)}));
|
||||
|
||||
// Nothing is retired twice on a later sweep.
|
||||
EXPECT_TRUE(takeCancelledRequests(queue).isEmpty());
|
||||
EXPECT_EQ(queuedUrls(queue), (QList<QUrl>{url(1), url(3)}));
|
||||
}
|
||||
|
||||
TEST(PictureLoaderRequestQueue, AllCancelledEmptiesTheQueue)
|
||||
{
|
||||
QQueue<TestRequest> queue;
|
||||
queue.enqueue(cancelled(1));
|
||||
queue.enqueue(cancelled(2));
|
||||
|
||||
EXPECT_EQ(takeCancelledRequests(queue), (QList<QUrl>{url(1), url(2)}));
|
||||
EXPECT_TRUE(queue.isEmpty());
|
||||
}
|
||||
|
|
@ -3,8 +3,12 @@
|
|||
#include <gtest/gtest.h>
|
||||
#include <initializer_list>
|
||||
#include <libcockatrice/network/client/abstract/latency_tracker.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
#include <string>
|
||||
|
||||
// Referenced by other translation units in the server_remote library.
|
||||
RNG_Abstract *rng = nullptr;
|
||||
|
||||
TEST(LatencyTrackerTest, EmptyTrackerYieldsZeroedStats)
|
||||
{
|
||||
LatencyTracker tracker;
|
||||
|
|
|
|||
|
|
@ -23,10 +23,14 @@
|
|||
#include <libcockatrice/card/printing/exact_card.h>
|
||||
#include <libcockatrice/card/set/card_set.h>
|
||||
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <qnamespace.h>
|
||||
#include <string>
|
||||
|
||||
// Referenced by other translation units in the server_remote library.
|
||||
RNG_Abstract *rng = nullptr;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -9,3 +9,15 @@ cockatrice_add_test(
|
|||
LABELS
|
||||
server
|
||||
)
|
||||
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
player_order_test
|
||||
SOURCES
|
||||
player_order_test.cpp
|
||||
LIBS
|
||||
libcockatrice_network_server_remote
|
||||
libcockatrice_rng
|
||||
LABELS
|
||||
server
|
||||
)
|
||||
|
|
|
|||
268
tests/movecard_tests/player_order_test.cpp
Normal file
268
tests/movecard_tests/player_order_test.cpp
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
#include "game/game_config.h"
|
||||
#include "game/role_info.h"
|
||||
#include "game/server_abstract_participant.h"
|
||||
#include "game/server_game.h"
|
||||
#include "server_abstractuserinterface.h"
|
||||
#include "server_response_containers.h"
|
||||
#include "server_room.h"
|
||||
#include "server_test_helpers.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/room_event.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||
#include <libcockatrice/protocol/pb/session_event.pb.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
|
||||
RNG_Abstract *rng = nullptr; // this needs to be defined due to other functions in server
|
||||
|
||||
class ScriptedRng : public RNG_Abstract
|
||||
{
|
||||
public:
|
||||
QList<unsigned int> values;
|
||||
|
||||
unsigned int rand(int min, int max) override
|
||||
{
|
||||
const unsigned int value = values.takeFirst();
|
||||
EXPECT_GE(value, static_cast<unsigned int>(min));
|
||||
EXPECT_LE(value, static_cast<unsigned int>(max));
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
class FakeUserInterface : public Server_AbstractUserInterface
|
||||
{
|
||||
public:
|
||||
FakeUserInterface(Server *_server, const ServerInfo_User &_userInfo) : Server_AbstractUserInterface(_server)
|
||||
{
|
||||
setUserInfo(_userInfo);
|
||||
}
|
||||
|
||||
int getLastCommandTime() const override
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool addSaidMessageSize(int) override
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void sendProtocolItem(const Response &) override
|
||||
{
|
||||
}
|
||||
|
||||
void sendProtocolItem(const SessionEvent &) override
|
||||
{
|
||||
}
|
||||
|
||||
void sendProtocolItem(const GameEventContainer &) override
|
||||
{
|
||||
}
|
||||
|
||||
void sendProtocolItem(const RoomEvent &) override
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
namespace
|
||||
{
|
||||
QStringList participantNamesInOrder(const Server_Game &game)
|
||||
{
|
||||
QStringList names;
|
||||
for (int id : game.getSeatOrder()) {
|
||||
const auto *participant = game.getParticipants().value(id, nullptr);
|
||||
if (participant) {
|
||||
names.append(QString::fromStdString(participant->getUserInfo()->name()));
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
QList<int> participantIdsInOrder(const Server_Game &game)
|
||||
{
|
||||
return game.getSeatOrder();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
class PlayerOrderTest : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
ServerInfo_User creator;
|
||||
ServerInfo_User userB;
|
||||
ServerInfo_User userC;
|
||||
|
||||
PlayerOrderTest()
|
||||
{
|
||||
creator.set_name("A");
|
||||
creator.set_user_level(ServerInfo_User::IsRegistered);
|
||||
userB.set_name("B");
|
||||
userB.set_user_level(ServerInfo_User::IsRegistered);
|
||||
userC.set_name("C");
|
||||
userC.set_user_level(ServerInfo_User::IsRegistered);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(PlayerOrderTest, JoinOrderIsSeatOrder)
|
||||
{
|
||||
FakeServer server;
|
||||
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
|
||||
FakeUserInterface uiA(&server, creator);
|
||||
FakeUserInterface uiB(&server, userB);
|
||||
FakeUserInterface uiC(&server, userC);
|
||||
GameConfig config{.creatorInfo = creator,
|
||||
.gameId = 1,
|
||||
.description = QString(),
|
||||
.password = QString(),
|
||||
.maxPlayers = 3,
|
||||
.gameTypes = QList<int>(),
|
||||
.onlyBuddies = false,
|
||||
.onlyRegistered = false,
|
||||
.spectatorsAllowed = false,
|
||||
.spectatorsNeedPassword = false,
|
||||
.spectatorsCanTalk = false,
|
||||
.spectatorsSeeEverything = false,
|
||||
.startingLifeTotal = 20,
|
||||
.shareDecklistsOnLoad = false,
|
||||
.shufflePlayers = false};
|
||||
Server_Game game(config, &room);
|
||||
ResponseContainer rc(0);
|
||||
|
||||
game.addPlayer(&uiA, rc, RoleInfo{}, false);
|
||||
game.addPlayer(&uiB, rc, RoleInfo{}, false);
|
||||
game.addPlayer(&uiC, rc, RoleInfo{}, false);
|
||||
|
||||
EXPECT_EQ(participantNamesInOrder(game), QStringList({"A", "B", "C"}));
|
||||
EXPECT_EQ(participantIdsInOrder(game), QList<int>({0, 1, 2}));
|
||||
EXPECT_EQ(game.getHostId(), 0);
|
||||
EXPECT_EQ(game.getPlayerCount(), 3);
|
||||
}
|
||||
|
||||
TEST_F(PlayerOrderTest, ReorderPlayerSeats)
|
||||
{
|
||||
FakeServer server;
|
||||
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
|
||||
FakeUserInterface uiA(&server, creator);
|
||||
FakeUserInterface uiB(&server, userB);
|
||||
FakeUserInterface uiC(&server, userC);
|
||||
GameConfig config{.creatorInfo = creator,
|
||||
.gameId = 1,
|
||||
.description = QString(),
|
||||
.password = QString(),
|
||||
.maxPlayers = 3,
|
||||
.gameTypes = QList<int>(),
|
||||
.onlyBuddies = false,
|
||||
.onlyRegistered = false,
|
||||
.spectatorsAllowed = false,
|
||||
.spectatorsNeedPassword = false,
|
||||
.spectatorsCanTalk = false,
|
||||
.spectatorsSeeEverything = false,
|
||||
.startingLifeTotal = 20,
|
||||
.shareDecklistsOnLoad = false,
|
||||
.shufflePlayers = false};
|
||||
Server_Game game(config, &room);
|
||||
ResponseContainer rc(0);
|
||||
|
||||
game.addPlayer(&uiA, rc, RoleInfo{}, false);
|
||||
game.addPlayer(&uiB, rc, RoleInfo{}, false);
|
||||
game.addPlayer(&uiC, rc, RoleInfo{}, false);
|
||||
|
||||
game.reorderPlayerSeats({"C", "A", "B"});
|
||||
|
||||
// player ids are stable; only the seat order changes
|
||||
EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"}));
|
||||
EXPECT_EQ(participantIdsInOrder(game), QList<int>({2, 0, 1}));
|
||||
EXPECT_EQ(game.getParticipants().size(), 3);
|
||||
|
||||
// the host keeps its seat: creator A is still in the game, so host stays 0
|
||||
EXPECT_EQ(game.getHostId(), 0);
|
||||
|
||||
// no re-keying means every mapping keyed by player id stays valid
|
||||
const int gameId = game.getGameId();
|
||||
EXPECT_EQ(uiA.getGames().value(gameId).second, 0);
|
||||
EXPECT_EQ(uiB.getGames().value(gameId).second, 1);
|
||||
EXPECT_EQ(uiC.getGames().value(gameId).second, 2);
|
||||
}
|
||||
|
||||
TEST_F(PlayerOrderTest, ReorderPlayerSeatsKeepsPlayerCount)
|
||||
{
|
||||
FakeServer server;
|
||||
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
|
||||
FakeUserInterface uiA(&server, creator);
|
||||
FakeUserInterface uiB(&server, userB);
|
||||
FakeUserInterface uiC(&server, userC);
|
||||
GameConfig config{.creatorInfo = creator,
|
||||
.gameId = 1,
|
||||
.description = QString(),
|
||||
.password = QString(),
|
||||
.maxPlayers = 3,
|
||||
.gameTypes = QList<int>(),
|
||||
.onlyBuddies = false,
|
||||
.onlyRegistered = false,
|
||||
.spectatorsAllowed = false,
|
||||
.spectatorsNeedPassword = false,
|
||||
.spectatorsCanTalk = false,
|
||||
.spectatorsSeeEverything = false,
|
||||
.startingLifeTotal = 20,
|
||||
.shareDecklistsOnLoad = false,
|
||||
.shufflePlayers = false};
|
||||
Server_Game game(config, &room);
|
||||
ResponseContainer rc(0);
|
||||
|
||||
game.addPlayer(&uiA, rc, RoleInfo{}, false);
|
||||
game.addPlayer(&uiB, rc, RoleInfo{}, false);
|
||||
game.addPlayer(&uiC, rc, RoleInfo{}, false);
|
||||
|
||||
// listing only a subset appends the remaining players in their current order
|
||||
game.reorderPlayerSeats({"C"});
|
||||
|
||||
EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"}));
|
||||
EXPECT_EQ(participantIdsInOrder(game), QList<int>({2, 0, 1}));
|
||||
EXPECT_EQ(game.getPlayerCount(), 3);
|
||||
EXPECT_EQ(game.getHostId(), 0);
|
||||
}
|
||||
|
||||
TEST_F(PlayerOrderTest, ShufflePlayerSeatsIsAPermutation)
|
||||
{
|
||||
FakeServer server;
|
||||
Server_Room room(0, 0, "", "", "", "", false, "", {}, &server);
|
||||
FakeUserInterface uiA(&server, creator);
|
||||
FakeUserInterface uiB(&server, userB);
|
||||
FakeUserInterface uiC(&server, userC);
|
||||
GameConfig config{.creatorInfo = creator,
|
||||
.gameId = 1,
|
||||
.description = QString(),
|
||||
.password = QString(),
|
||||
.maxPlayers = 3,
|
||||
.gameTypes = QList<int>(),
|
||||
.onlyBuddies = false,
|
||||
.onlyRegistered = false,
|
||||
.spectatorsAllowed = false,
|
||||
.spectatorsNeedPassword = false,
|
||||
.spectatorsCanTalk = false,
|
||||
.spectatorsSeeEverything = false,
|
||||
.startingLifeTotal = 20,
|
||||
.shareDecklistsOnLoad = false,
|
||||
.shufflePlayers = false};
|
||||
Server_Game game(config, &room);
|
||||
ResponseContainer rc(0);
|
||||
|
||||
game.addPlayer(&uiA, rc, RoleInfo{}, false);
|
||||
game.addPlayer(&uiB, rc, RoleInfo{}, false);
|
||||
game.addPlayer(&uiC, rc, RoleInfo{}, false);
|
||||
|
||||
// fisher-yates on [A, B, C]: rand(0, 2) -> 1 (take B), rand(0, 1) -> 0 (take A), rand(0, 0) -> 0 (take C)
|
||||
auto *scriptedRng = new ScriptedRng;
|
||||
scriptedRng->values = {1, 0, 0};
|
||||
rng = scriptedRng;
|
||||
|
||||
game.shufflePlayerSeats();
|
||||
|
||||
EXPECT_EQ(participantNamesInOrder(game), QStringList({"B", "A", "C"}));
|
||||
EXPECT_EQ(participantIdsInOrder(game), QList<int>({1, 0, 2}));
|
||||
QSet<int> idSet(participantIdsInOrder(game).cbegin(), participantIdsInOrder(game).cend());
|
||||
EXPECT_EQ(idSet.size(), 3); // ids are unique
|
||||
// ids are stable, so the host (creator A, id 0) is unchanged
|
||||
EXPECT_EQ(game.getHostId(), 0);
|
||||
}
|
||||
84
tests/moxfield_signature_spells_test.cpp
Normal file
84
tests/moxfield_signature_spells_test.cpp
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#include "client/network/parsers/interface_json_deck_parser.h"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
|
||||
TEST(MoxfieldImport, SignatureSpellIsAddedToMainWithoutReplacingCommanderBanner)
|
||||
{
|
||||
// Moxfield v2 board objects map IDs to entries with quantity and card.
|
||||
const auto input = QJsonDocument::fromJson(R"json({
|
||||
"name": "Oathbreaker example", "description": "Notes retained",
|
||||
"mainboard": {"land": {"quantity": 1, "card": {"name": "Island", "set": "abc", "cn": "1"}}},
|
||||
"sideboard": {"side": {"quantity": 1, "card": {"name": "Mountain", "set": "abc", "cn": "2"}}},
|
||||
"commanders": {"walker": {"quantity": 1, "card": {"name": "Jace", "set": "abc", "cn": "3", "scryfall_id": "walker-id"}}},
|
||||
"signatureSpells": {"spell": {"quantity": 1, "card": {"name": "Lightning Bolt", "set": "xyz", "cn": "4", "scryfall_id": "spell-id"}}}
|
||||
})json")
|
||||
.object();
|
||||
MoxfieldJsonParser parser;
|
||||
DeckList deck = parser.parse(input);
|
||||
int mainSpellCount = 0;
|
||||
int sideSpellCount = 0;
|
||||
int mainLands = 0;
|
||||
int sideLands = 0;
|
||||
deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) {
|
||||
if (card->getName() == "Island" && zone->getName() == DECK_ZONE_MAIN) {
|
||||
mainLands += card->getNumber();
|
||||
}
|
||||
if (card->getName() == "Mountain" && zone->getName() == DECK_ZONE_SIDE) {
|
||||
sideLands += card->getNumber();
|
||||
}
|
||||
if (card->getName() != "Lightning Bolt") {
|
||||
return;
|
||||
}
|
||||
if (zone->getName() == DECK_ZONE_MAIN) {
|
||||
mainSpellCount += card->getNumber();
|
||||
EXPECT_EQ(card->getCardSetShortName(), "XYZ");
|
||||
EXPECT_EQ(card->getCardCollectorNumber(), "4");
|
||||
EXPECT_EQ(card->getCardProviderId(), "spell-id");
|
||||
} else if (zone->getName() == DECK_ZONE_SIDE) {
|
||||
sideSpellCount += card->getNumber();
|
||||
}
|
||||
});
|
||||
EXPECT_EQ(mainSpellCount, 1);
|
||||
EXPECT_EQ(sideSpellCount, 0);
|
||||
EXPECT_EQ(mainLands, 1);
|
||||
EXPECT_EQ(sideLands, 1);
|
||||
EXPECT_EQ(deck.getName(), "Oathbreaker example");
|
||||
EXPECT_EQ(deck.getComments(), "Notes retained");
|
||||
EXPECT_EQ(deck.getBannerCard().name, "Jace");
|
||||
EXPECT_EQ(deck.getBannerCard().providerId, "walker-id");
|
||||
}
|
||||
|
||||
TEST(MoxfieldImport, QuantityAwareDeckInsertionRetainsCountAndPrinting)
|
||||
{
|
||||
DeckList deck;
|
||||
auto *card = deck.addCards("Island", 2, DECK_ZONE_MAIN, -1, "ABC", "4", "id");
|
||||
ASSERT_NE(card, nullptr);
|
||||
EXPECT_EQ(card->getNumber(), 2);
|
||||
EXPECT_EQ(card->getCardProviderId(), "id");
|
||||
EXPECT_EQ(deck.addCards("Island", 0, DECK_ZONE_MAIN), nullptr);
|
||||
}
|
||||
|
||||
TEST(OnlineImport, ArchidektPreservesNameDescriptionAndCards)
|
||||
{
|
||||
const auto input = QJsonDocument::fromJson(R"json({
|
||||
"name": "Online deck", "description": "Deck notes",
|
||||
"cards": [{"quantity": 2, "card": {
|
||||
"oracleCard": {"name": "Island"}, "edition": {"editioncode": "abc"}, "collectorNumber": "1"
|
||||
}}]
|
||||
})json")
|
||||
.object();
|
||||
ArchidektJsonParser parser;
|
||||
const DeckList deck = parser.parse(input);
|
||||
int count = 0;
|
||||
deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) {
|
||||
if (zone->getName() == DECK_ZONE_MAIN && card->getName() == "Island") {
|
||||
count += card->getNumber();
|
||||
}
|
||||
});
|
||||
EXPECT_EQ(count, 2);
|
||||
EXPECT_EQ(deck.getName(), "Online deck");
|
||||
EXPECT_EQ(deck.getComments(), "Deck notes");
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ cockatrice_add_test(
|
|||
../../oracle/src/parsehelpers.cpp
|
||||
../../oracle/src/raw_json_scanner.cpp
|
||||
../../oracle/src/tag_database.cpp
|
||||
../../oracle/src/database_writer.cpp
|
||||
oracle_importer_test.cpp
|
||||
${_ORACLE_COMPRESSION_SOURCES}
|
||||
LIBS
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
#include "../../oracle/src/database_writer.h"
|
||||
#include "../../oracle/src/oracleimporter.h"
|
||||
#include "../../oracle/src/raw_json_scanner.h"
|
||||
#include "../../oracle/src/tag_database.h"
|
||||
#include "../support/scoped_file_size_limit.h"
|
||||
#include "libcockatrice/card/card_info.h"
|
||||
|
||||
#include "gtest/gtest.h"
|
||||
#include <QByteArray>
|
||||
#include <QChar>
|
||||
#include <QDate>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
|
@ -21,6 +24,7 @@
|
|||
#include <QRegularExpressionMatch>
|
||||
#include <QSharedPointer>
|
||||
#include <QString>
|
||||
#include <QTemporaryDir>
|
||||
#include <QUuid>
|
||||
#include <QVariant>
|
||||
#include <QVariantMap>
|
||||
|
|
@ -1293,6 +1297,208 @@ TEST_F(OracleImporterTest, StartImportAppliesLocalizedData)
|
|||
ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo.");
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
QByteArray refreshSource(const QString &name = "New Card")
|
||||
{
|
||||
QJsonObject card{{"name", name}, {"text", "Rules text."}, {"layout", "normal"},
|
||||
{"type", "Artifact"}, {"number", "1"}, {"rarity", "common"}};
|
||||
QJsonObject set{{"code", "TST"}, {"name", "Test Set"}, {"type", "expansion"}, {"cards", QJsonArray{card}}};
|
||||
return QJsonDocument(QJsonObject{{"data", QJsonObject{{"TST", set}}}}).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_F(OracleImporterTest, FailedLazyParseKeepsInstalledDatabase)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = dir.filePath("cards.xml");
|
||||
QFile original(path);
|
||||
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
|
||||
ASSERT_EQ(original.write("existing database"), 17);
|
||||
original.close();
|
||||
|
||||
// The metadata scanner skips numeric values without converting them. Qt's
|
||||
// per-set parser rejects an overflowing number after selection succeeds.
|
||||
auto badSet = QJsonDocument::fromJson(refreshSource()).object().value("data").toObject().value("TST").toObject();
|
||||
auto goodSet = badSet;
|
||||
goodSet["code"] = "AAA";
|
||||
goodSet["name"] = "A successful first set";
|
||||
auto badCard = badSet.value("cards").toArray().first().toObject();
|
||||
badCard["number"] = "overflow";
|
||||
badSet["cards"] = QJsonArray{badCard};
|
||||
QByteArray data = QJsonDocument(QJsonObject{{"data", QJsonObject{{"AAA", goodSet}, {"TST", badSet}}}})
|
||||
.toJson(QJsonDocument::Compact);
|
||||
data.replace("\"number\":\"overflow\"", "\"number\":1e9999");
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(data));
|
||||
EXPECT_EQ(importer->startImport(), 0);
|
||||
EXPECT_FALSE(importer->hasSuccessfulImport());
|
||||
EXPECT_TRUE(importer->getImportError().contains("TST"));
|
||||
EXPECT_TRUE(importer->getCardList().isEmpty());
|
||||
EXPECT_FALSE(importer->saveToFile(path, {}, {}));
|
||||
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
|
||||
EXPECT_EQ(original.readAll(), "existing database");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, EmptyImportCannotReplaceInstalledCards)
|
||||
{
|
||||
const QByteArray source = R"({"data":{"TST":{"code":"TST","name":"Test","type":"expansion","cards":[]}}})";
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(source));
|
||||
EXPECT_EQ(importer->startImport(), 0);
|
||||
EXPECT_FALSE(importer->hasSuccessfulImport());
|
||||
EXPECT_FALSE(importer->getImportError().isEmpty());
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, MissingSourceBytesCannotProduceSuccessfulImport)
|
||||
{
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
|
||||
const auto selected = importer->getSets();
|
||||
importer->releaseSetData();
|
||||
importer->getSets() = selected;
|
||||
EXPECT_EQ(importer->startImport(), 0);
|
||||
EXPECT_FALSE(importer->hasSuccessfulImport());
|
||||
EXPECT_TRUE(importer->getImportError().contains("TST"));
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, CancellationDuringFinalSetCannotSavePartialDatabase)
|
||||
{
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
|
||||
QObject::connect(
|
||||
importer, &OracleImporter::setIndexChanged, importer,
|
||||
[this](int, int, const QString &name) {
|
||||
if (!name.isEmpty()) {
|
||||
importer->cancelImport();
|
||||
}
|
||||
},
|
||||
Qt::DirectConnection);
|
||||
EXPECT_EQ(importer->startImport(), 0);
|
||||
EXPECT_FALSE(importer->hasSuccessfulImport());
|
||||
EXPECT_TRUE(importer->getCardList().isEmpty());
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, CancellationBeforeWorkerStartsIsNotLost)
|
||||
{
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
|
||||
importer->prepareImport();
|
||||
importer->cancelImport();
|
||||
EXPECT_EQ(importer->startImport(), 0);
|
||||
EXPECT_FALSE(importer->hasSuccessfulImport());
|
||||
importer->prepareImport();
|
||||
EXPECT_EQ(importer->startImport(), 1);
|
||||
EXPECT_TRUE(importer->hasSuccessfulImport());
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, NewImportReplacesPreviousInMemoryCards)
|
||||
{
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("Old Card")));
|
||||
ASSERT_EQ(importer->startImport(), 1);
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("New Card")));
|
||||
EXPECT_FALSE(importer->hasSuccessfulImport());
|
||||
ASSERT_EQ(importer->startImport(), 1);
|
||||
EXPECT_EQ(importer->getCardList().size(), 1);
|
||||
EXPECT_FALSE(importer->getCardList().contains("Old Card"));
|
||||
EXPECT_TRUE(importer->getCardList().contains("New Card"));
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, FailedSourceLoadCannotReusePreviousSuccessfulImport)
|
||||
{
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
|
||||
ASSERT_EQ(importer->startImport(), 1);
|
||||
ASSERT_FALSE(importer->readSetsFromByteArray("invalid JSON"));
|
||||
EXPECT_FALSE(importer->hasSuccessfulImport());
|
||||
EXPECT_EQ(importer->startImport(), 0);
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, SuccessfulRefreshCommitsCompleteXml)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = dir.filePath("cards.xml");
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource()));
|
||||
ASSERT_EQ(importer->startImport(), 1);
|
||||
importer->releaseSetData();
|
||||
ASSERT_TRUE(importer->saveToFile(path, "test-source", "v1"));
|
||||
QFile file(path);
|
||||
ASSERT_TRUE(file.open(QIODevice::ReadOnly));
|
||||
const QByteArray xml = file.readAll();
|
||||
EXPECT_TRUE(xml.contains("New Card"));
|
||||
EXPECT_TRUE(xml.contains("test-source"));
|
||||
EXPECT_TRUE(xml.contains("</cockatrice_carddatabase>"));
|
||||
}
|
||||
|
||||
TEST(OracleDatabaseWriter, AcceptsCompleteSupportedVersions)
|
||||
{
|
||||
EXPECT_TRUE(OracleDatabase::validateXml(
|
||||
"<?xml version=\"1.0\"?><cockatrice_carddatabase version=\"3\"><cards/></cockatrice_carddatabase>")
|
||||
.isEmpty());
|
||||
EXPECT_TRUE(OracleDatabase::validateXml("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>")
|
||||
.isEmpty());
|
||||
EXPECT_FALSE(
|
||||
OracleDatabase::validateXml("<cockatrice_carddatabase version=\"5\"><cards/></cockatrice_carddatabase>")
|
||||
.isEmpty());
|
||||
}
|
||||
|
||||
TEST(OracleDatabaseWriter, InvalidDownloadPreservesInstalledBytes)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = dir.filePath("tokens.xml");
|
||||
QFile original(path);
|
||||
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
|
||||
ASSERT_EQ(original.write("installed tokens"), 16);
|
||||
original.close();
|
||||
for (const QByteArray &payload : {QByteArray("<html>server error</html>"),
|
||||
QByteArray("<cockatrice_carddatabase version=\"4\"><cards>"), QByteArray()}) {
|
||||
QString error;
|
||||
EXPECT_FALSE(OracleDatabase::saveXml(path, payload, &error));
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
|
||||
EXPECT_EQ(original.readAll(), "installed tokens");
|
||||
original.close();
|
||||
}
|
||||
}
|
||||
|
||||
TEST(OracleDatabaseWriter, CompleteDownloadReplacesInstalledBytes)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = dir.filePath("cards.xml");
|
||||
const QByteArray data("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>");
|
||||
QString error = "previous failure";
|
||||
ASSERT_TRUE(OracleDatabase::saveXml(path, data, &error));
|
||||
EXPECT_TRUE(error.isEmpty());
|
||||
QFile file(path);
|
||||
ASSERT_TRUE(file.open(QIODevice::ReadOnly));
|
||||
EXPECT_EQ(file.readAll(), data);
|
||||
}
|
||||
|
||||
#ifdef Q_OS_UNIX
|
||||
TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
const QString path = dir.filePath("cards.xml");
|
||||
QFile original(path);
|
||||
ASSERT_TRUE(original.open(QIODevice::WriteOnly));
|
||||
ASSERT_EQ(original.write("installed database"), 18);
|
||||
original.close();
|
||||
const QByteArray data = "<cockatrice_carddatabase version=\"4\"><!--" + QByteArray(16384, 'x') +
|
||||
"--><cards/></cockatrice_carddatabase>";
|
||||
// A real filesystem write failure: the soft file-size cap makes the writer's own
|
||||
// flush fail without filling a disk. The cap is scoped rather than forked, because a
|
||||
// death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS.
|
||||
test_support::ScopedFileSizeLimit fileSizeCap(128);
|
||||
ASSERT_TRUE(fileSizeCap.isValid());
|
||||
|
||||
QString error;
|
||||
EXPECT_FALSE(OracleDatabase::saveXml(path, data, &error));
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
|
||||
ASSERT_TRUE(original.open(QIODevice::ReadOnly));
|
||||
EXPECT_EQ(original.readAll(), "installed database");
|
||||
}
|
||||
#endif
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
|
|
|
|||
151
tests/random_reveal_test.cpp
Normal file
151
tests/random_reveal_test.cpp
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
#include "game/game_config.h"
|
||||
#include "game/server_abstract_player.h"
|
||||
#include "game/server_card.h"
|
||||
#include "game/server_cardzone.h"
|
||||
#include "game/server_game.h"
|
||||
#include "movecard_tests/server_test_helpers.h"
|
||||
#include "server_abstractuserinterface.h"
|
||||
#include "server_response_containers.h"
|
||||
#include "server_room.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_select.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_reveal_cards.pb.h>
|
||||
#include <libcockatrice/protocol/pb/event_reveal_cards.pb.h>
|
||||
#include <libcockatrice/protocol/pb/game_event_container.pb.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
#include <libcockatrice/utility/zone_names.h>
|
||||
#include <memory>
|
||||
|
||||
namespace
|
||||
{
|
||||
class FirstRng : public RNG_Abstract
|
||||
{
|
||||
unsigned int rand(int min, int) override
|
||||
{
|
||||
return min;
|
||||
}
|
||||
} firstRng;
|
||||
|
||||
class User : public Server_AbstractUserInterface
|
||||
{
|
||||
public:
|
||||
User(Server *server, const char *name) : Server_AbstractUserInterface(server)
|
||||
{
|
||||
ServerInfo_User info;
|
||||
info.set_name(name);
|
||||
setUserInfo(info);
|
||||
}
|
||||
int getLastCommandTime() const override
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
bool addSaidMessageSize(int) override
|
||||
{
|
||||
return true;
|
||||
}
|
||||
void sendProtocolItem(const Response &) override
|
||||
{
|
||||
}
|
||||
void sendProtocolItem(const SessionEvent &) override
|
||||
{
|
||||
}
|
||||
void sendProtocolItem(const RoomEvent &) override
|
||||
{
|
||||
}
|
||||
void sendProtocolItem(const GameEventContainer &) override
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
class RandomReveal : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
FakeServer server;
|
||||
User owner{&server, "Owner"}, target{&server, "Target"};
|
||||
Server_Room room{0, 0, "", "", "", "", false, "", {}, &server};
|
||||
std::unique_ptr<Server_Game> game;
|
||||
Server_AbstractPlayer *player;
|
||||
Server_CardZone *hand;
|
||||
ResponseContainer response{1};
|
||||
|
||||
void SetUp() override
|
||||
{
|
||||
GameConfig config;
|
||||
config.creatorInfo = *owner.getUserInfo();
|
||||
config.gameId = 1;
|
||||
config.maxPlayers = 2;
|
||||
config.shareDecklistsOnLoad = false;
|
||||
game = std::make_unique<Server_Game>(config, &room);
|
||||
game->addPlayer(&owner, response, {});
|
||||
game->addPlayer(&target, response, {});
|
||||
for (auto *participant : game->getPlayers()) {
|
||||
GameCommand command;
|
||||
command.MutableExtension(Command_DeckSelect::ext)
|
||||
->set_deck(DeckList().writeToString_Native().toStdString());
|
||||
GameEventStorage events;
|
||||
ASSERT_EQ(participant->processGameCommand(command, response, events), Response::RespOk);
|
||||
participant->setReadyStart(true);
|
||||
}
|
||||
game->startGameIfReady(false);
|
||||
QCoreApplication::processEvents();
|
||||
ASSERT_TRUE(game->getGameStarted());
|
||||
player = game->getPlayer(0);
|
||||
hand = player->getZones().value(ZoneNames::HAND);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
hand->insertCard(
|
||||
new Server_Card({.name = "Twin", .providerId = QString::number(i)}, player->newCardId(), i, 0), -1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Response::ResponseCode reveal(int count, GameEventStorage &events)
|
||||
{
|
||||
GameCommand command;
|
||||
auto *request = command.MutableExtension(Command_RevealRandomCards::ext);
|
||||
request->set_zone_name(ZoneNames::HAND);
|
||||
request->set_player_id(1);
|
||||
request->set_number_of_cards(count);
|
||||
return player->processGameCommand(command, response, events);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(RandomReveal, DistinctPhysicalCardsArePrivateAndLeaveTheHandUnchanged)
|
||||
{
|
||||
const auto original = hand->getCards();
|
||||
GameEventStorage events;
|
||||
ASSERT_EQ(reveal(2, events), Response::RespOk);
|
||||
ASSERT_EQ(events.getGameEventList().size(), 2);
|
||||
EXPECT_EQ(events.getPrivatePlayerId(), 1);
|
||||
const auto *privateEvent = events.getGameEventList().at(0);
|
||||
EXPECT_EQ(privateEvent->getRecipients(), GameEventStorageItem::SendToPrivate);
|
||||
const auto &cards = privateEvent->getGameEvent().GetExtension(Event_RevealCards::ext);
|
||||
ASSERT_EQ(cards.cards_size(), 2);
|
||||
EXPECT_NE(cards.cards(0).id(), cards.cards(1).id());
|
||||
const auto *publicEvent = events.getGameEventList().at(1);
|
||||
EXPECT_EQ(publicEvent->getRecipients(), GameEventStorageItem::SendToOthers);
|
||||
const auto ¬ice = publicEvent->getGameEvent().GetExtension(Event_RevealCards::ext);
|
||||
EXPECT_EQ(notice.cards_size(), 0);
|
||||
EXPECT_EQ(notice.number_of_cards(), 2);
|
||||
EXPECT_EQ(hand->getCards(), original);
|
||||
}
|
||||
|
||||
TEST_F(RandomReveal, InvalidCountsDoNotQueueAnyReveal)
|
||||
{
|
||||
for (int count : {0, -1, 5}) {
|
||||
GameEventStorage events;
|
||||
EXPECT_NE(reveal(count, events), Response::RespOk);
|
||||
EXPECT_TRUE(events.getGameEventList().isEmpty());
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
RNG_Abstract *rng = &firstRng;
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QCoreApplication application(argc, argv);
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
|
@ -8,9 +8,13 @@
|
|||
#include <QString>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/network/server/remote/game/server_counter.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
// Referenced by other translation units in the server_remote library.
|
||||
RNG_Abstract *rng = nullptr;
|
||||
|
||||
TEST(ServerCounter, IncrementDoesNotOverflow)
|
||||
{
|
||||
Server_Counter c(1, "test", color(), 10, std::numeric_limits<int>::max());
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@
|
|||
#include <climits>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/network/server/remote/game/dump_zone_range.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
#include <vector>
|
||||
|
||||
// Referenced by other translation units in the server_remote library.
|
||||
RNG_Abstract *rng = nullptr;
|
||||
|
||||
namespace
|
||||
{
|
||||
/// Indices a dump request resolves to, as the handler would walk them.
|
||||
|
|
|
|||
135
tests/spoiler_file_test.cpp
Normal file
135
tests/spoiler_file_test.cpp
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
#include "client/network/update/card_spoiler/spoiler_file.h"
|
||||
#include "support/scoped_file_size_limit.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QTemporaryDir>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
class SpoilerFileTest : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
QTemporaryDir directory;
|
||||
QString path;
|
||||
QString error;
|
||||
const QByteArray original = "<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase>";
|
||||
|
||||
void SetUp() override
|
||||
{
|
||||
ASSERT_TRUE(directory.isValid());
|
||||
path = directory.filePath("spoiler.xml");
|
||||
QFile file(path);
|
||||
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
|
||||
ASSERT_EQ(file.write(original), original.size());
|
||||
}
|
||||
|
||||
QByteArray read()
|
||||
{
|
||||
QFile file(path);
|
||||
EXPECT_TRUE(file.open(QIODevice::ReadOnly));
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
SpoilerFile::SaveResult save(const QByteArray &data)
|
||||
{
|
||||
return SpoilerFile::save(path, data, [] { return true; }, error);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(SpoilerFileTest, InvalidDocumentsPreserveExistingFile)
|
||||
{
|
||||
for (const QByteArray &invalid :
|
||||
{QByteArray(), QByteArray("<html><cards/></html>"),
|
||||
QByteArray("<cockatrice_carddatabase version=\"2\"><cards/></cockatrice_carddatabase>"),
|
||||
QByteArray("<cockatrice_carddatabase><cards/></cockatrice_carddatabase>"),
|
||||
QByteArray("<cockatrice_carddatabase version=\"4\"><sets/></cockatrice_carddatabase>"),
|
||||
QByteArray("<cockatrice_carddatabase version=\"4\"><cards>"),
|
||||
QByteArray("<cockatrice_carddatabase version=\"4\"><cards/></cockatrice_carddatabase><extra/>")}) {
|
||||
EXPECT_EQ(save(invalid), SpoilerFile::SaveResult::Failed);
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
EXPECT_EQ(read(), original);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(SpoilerFileTest, SupportedVersionsAreAccepted)
|
||||
{
|
||||
EXPECT_TRUE(SpoilerFile::validate(original).isEmpty());
|
||||
EXPECT_TRUE(
|
||||
SpoilerFile::validate("<cockatrice_carddatabase version=\"3\"><cards/></cockatrice_carddatabase>").isEmpty());
|
||||
}
|
||||
|
||||
TEST_F(SpoilerFileTest, IdenticalFileIsUnchanged)
|
||||
{
|
||||
EXPECT_EQ(save(original), SpoilerFile::SaveResult::Unchanged);
|
||||
EXPECT_TRUE(error.isEmpty());
|
||||
EXPECT_EQ(read(), original);
|
||||
}
|
||||
|
||||
TEST_F(SpoilerFileTest, CompleteFileHashDetectsChangesAfterLargeCommonPrefix)
|
||||
{
|
||||
QByteArray changed = "<cockatrice_carddatabase version=\"4\"><!--";
|
||||
changed += QByteArray(100000, 'x');
|
||||
changed += "--><cards/></cockatrice_carddatabase>";
|
||||
ASSERT_EQ(save(changed), SpoilerFile::SaveResult::Written);
|
||||
changed.replace("<cards/>", "<cards></cards>");
|
||||
EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Written);
|
||||
EXPECT_EQ(read(), changed);
|
||||
EXPECT_TRUE(error.isEmpty());
|
||||
}
|
||||
|
||||
TEST_F(SpoilerFileTest, CancellationBeforeWritePreservesExistingFile)
|
||||
{
|
||||
EXPECT_EQ(SpoilerFile::save(path, original, [] { return false; }, error), SpoilerFile::SaveResult::Failed);
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
EXPECT_EQ(read(), original);
|
||||
}
|
||||
|
||||
TEST_F(SpoilerFileTest, CancellationBeforeCommitPreservesExistingFile)
|
||||
{
|
||||
int checks = 0;
|
||||
EXPECT_EQ(SpoilerFile::save(
|
||||
path, original + "\n", [&checks] { return ++checks == 1; }, error),
|
||||
SpoilerFile::SaveResult::Failed);
|
||||
EXPECT_EQ(checks, 2);
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
EXPECT_EQ(read(), original);
|
||||
EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"});
|
||||
}
|
||||
|
||||
TEST_F(SpoilerFileTest, MissingParentIsCreated)
|
||||
{
|
||||
path = directory.filePath("nested/spoiler.xml");
|
||||
EXPECT_EQ(save(original), SpoilerFile::SaveResult::Written);
|
||||
EXPECT_EQ(read(), original);
|
||||
}
|
||||
|
||||
TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt)
|
||||
{
|
||||
EXPECT_EQ(SpoilerFile::save(
|
||||
QDir(path).filePath("spoiler.xml"), original, [] { return true; }, error),
|
||||
SpoilerFile::SaveResult::Failed);
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
EXPECT_EQ(read(), original);
|
||||
}
|
||||
|
||||
#ifdef Q_OS_UNIX
|
||||
TEST_F(SpoilerFileTest, ShortWriteLeavesExistingFileIntact)
|
||||
{
|
||||
const QByteArray changed = "<cockatrice_carddatabase version=\"4\"><!--" + QByteArray(100000, 'x') +
|
||||
"--><cards/></cockatrice_carddatabase>";
|
||||
// A real short write exercises QSaveFile's buffered flush failure without depending
|
||||
// on disk capacity. The cap is scoped rather than forked, because a death-test child
|
||||
// under RLIMIT_FSIZE is killed by SIGSEGV on macOS.
|
||||
test_support::ScopedFileSizeLimit fileSizeCap(128);
|
||||
ASSERT_TRUE(fileSizeCap.isValid());
|
||||
|
||||
EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Failed);
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
EXPECT_EQ(read(), original);
|
||||
EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"});
|
||||
}
|
||||
#endif
|
||||
} // namespace
|
||||
83
tests/support/scoped_file_size_limit.h
Normal file
83
tests/support/scoped_file_size_limit.h
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#ifndef COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H
|
||||
#define COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H
|
||||
|
||||
#include <QtGlobal>
|
||||
|
||||
#ifdef Q_OS_UNIX
|
||||
|
||||
#include <algorithm>
|
||||
#include <csignal>
|
||||
#include <sys/resource.h>
|
||||
|
||||
namespace test_support
|
||||
{
|
||||
/**
|
||||
* @brief Caps how large a single file may grow, so a writer's short-write path is reachable.
|
||||
*
|
||||
* Lowering RLIMIT_FSIZE makes the next write past the cap fail with EFBIG instead of
|
||||
* filling a filesystem, which is the only way to fail an install step mid-write without
|
||||
* depending on free disk space or file ownership. Only the soft limit is lowered, so the
|
||||
* destructor can hand the process back exactly as it found it: ctest runs one case per
|
||||
* process, but a whole test binary must not carry the cap into its neighbours.
|
||||
*
|
||||
* Do not use this inside a gtest death test. A forked child under RLIMIT_FSIZE is killed
|
||||
* by SIGSEGV on the macOS runners, while the same case passes on Linux, so the fork is
|
||||
* what breaks - not the writer. The guard alone isolates the failure just as well.
|
||||
*/
|
||||
class ScopedFileSizeLimit
|
||||
{
|
||||
public:
|
||||
explicit ScopedFileSizeLimit(rlim_t maxBytes)
|
||||
{
|
||||
if (getrlimit(RLIMIT_FSIZE, &previousLimit) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
struct sigaction ignore = {};
|
||||
ignore.sa_handler = SIG_IGN;
|
||||
sigemptyset(&ignore.sa_mask);
|
||||
if (sigaction(SIGXFSZ, &ignore, &previousAction) != 0) {
|
||||
return;
|
||||
}
|
||||
actionSaved = true;
|
||||
|
||||
struct rlimit capped = previousLimit;
|
||||
capped.rlim_cur = std::min(maxBytes, previousLimit.rlim_max);
|
||||
if (setrlimit(RLIMIT_FSIZE, &capped) != 0) {
|
||||
return;
|
||||
}
|
||||
active = true;
|
||||
}
|
||||
|
||||
~ScopedFileSizeLimit()
|
||||
{
|
||||
if (active) {
|
||||
setrlimit(RLIMIT_FSIZE, &previousLimit);
|
||||
}
|
||||
if (actionSaved) {
|
||||
sigaction(SIGXFSZ, &previousAction, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
ScopedFileSizeLimit(const ScopedFileSizeLimit &) = delete;
|
||||
ScopedFileSizeLimit &operator=(const ScopedFileSizeLimit &) = delete;
|
||||
|
||||
/**
|
||||
* @brief Whether the cap is in force; a test that needs the failure must assert this.
|
||||
*/
|
||||
[[nodiscard]] bool isValid() const
|
||||
{
|
||||
return active;
|
||||
}
|
||||
|
||||
private:
|
||||
bool active = false;
|
||||
bool actionSaved = false;
|
||||
struct rlimit previousLimit = {};
|
||||
struct sigaction previousAction = {};
|
||||
};
|
||||
} // namespace test_support
|
||||
|
||||
#endif // Q_OS_UNIX
|
||||
|
||||
#endif // COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H
|
||||
119
tests/svg_recolor_test.cpp
Normal file
119
tests/svg_recolor_test.cpp
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
TEST(SvgRecolor, ReplacesEverySentinel)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path fill="currentColor"/><path stroke="currentColor"/></svg>)";
|
||||
const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800");
|
||||
|
||||
EXPECT_EQ(result.count("#ff8800"), 2);
|
||||
EXPECT_FALSE(result.contains("currentColor"));
|
||||
}
|
||||
|
||||
TEST(SvgRecolor, ReplacesSentinelRegardlessOfCase)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path fill="CurrentColor"/><path stroke="CURRENTCOLOR"/></svg>)";
|
||||
|
||||
EXPECT_EQ(resolveSvgColorSentinel(svg, "#ff8800").count("#ff8800"), 2);
|
||||
}
|
||||
|
||||
TEST(SvgRecolor, LeavesTheRestOfTheDocumentIntact)
|
||||
{
|
||||
const QByteArray svg =
|
||||
R"(<svg viewBox="0 0 24 24"><!-- an ordinary comment --><path d="M0 0h24v24" fill="currentColor"/></svg>)";
|
||||
const QByteArray result = resolveSvgColorSentinel(svg, "#123456");
|
||||
|
||||
EXPECT_TRUE(result.contains(R"(viewBox="0 0 24 24")"));
|
||||
EXPECT_TRUE(result.contains(R"(d="M0 0h24v24")"));
|
||||
EXPECT_TRUE(result.contains("an ordinary comment"));
|
||||
}
|
||||
|
||||
TEST(SvgRecolor, DoesNotModifyTheInput)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path fill="currentColor"/></svg>)";
|
||||
const QByteArray original = svg;
|
||||
|
||||
resolveSvgColorSentinel(svg, "#000000");
|
||||
|
||||
EXPECT_EQ(svg, original);
|
||||
}
|
||||
|
||||
TEST(SvgRecolor, PreservesBytesThatAreNotValidUtf8)
|
||||
{
|
||||
QByteArray svg = "<svg>";
|
||||
svg.append("\xff\xfe");
|
||||
svg.append(R"(<path fill="currentColor"/></svg>)");
|
||||
|
||||
const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800");
|
||||
|
||||
EXPECT_TRUE(result.contains("\xff\xfe"));
|
||||
EXPECT_FALSE(result.contains("currentColor"));
|
||||
EXPECT_EQ(result.count("#ff8800"), 1);
|
||||
}
|
||||
|
||||
TEST(SvgRecolor, NoSentinelIsANoOp)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path fill="#abcdef"/></svg>)";
|
||||
|
||||
EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, RewritesEveryAttribute)
|
||||
{
|
||||
const QByteArray svg = R"(<svg stroke-width="2"><path stroke-width="1.5"/><path stroke-width='3'/></svg>)";
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 4.25);
|
||||
|
||||
EXPECT_EQ(result.count(R"(stroke-width="4.25")"), 2);
|
||||
EXPECT_EQ(result.count(R"(stroke-width='4.25')"), 1);
|
||||
EXPECT_FALSE(result.contains("1.5"));
|
||||
EXPECT_FALSE(result.contains("3'/"));
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, LeavesCssDeclarationsAlone)
|
||||
{
|
||||
const QByteArray svg = R"(<svg><path style="stroke-width:2"/><path stroke-width="2"/></svg>)";
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 3.0);
|
||||
|
||||
EXPECT_TRUE(result.contains(R"(style="stroke-width:2")"));
|
||||
EXPECT_EQ(result.count(R"(stroke-width="3.00")"), 1);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, NoAttributeIsANoOp)
|
||||
{
|
||||
const QByteArray svg = R"(<svg stroke-linecap="round"><path d="M0 0h24"/></svg>)";
|
||||
|
||||
EXPECT_EQ(resolveSvgStrokeWidth(svg, 3.0), svg);
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, DoesNotModifyTheInputAndPreservesBytes)
|
||||
{
|
||||
QByteArray svg = "<svg>";
|
||||
svg.append("\xff\xfe");
|
||||
svg.append(R"(<path stroke-width="2"/></svg>)");
|
||||
const QByteArray original = svg;
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 3.5);
|
||||
|
||||
EXPECT_EQ(svg, original);
|
||||
EXPECT_TRUE(result.contains("\xff\xfe"));
|
||||
EXPECT_TRUE(result.contains(R"(stroke-width="3.50")"));
|
||||
}
|
||||
|
||||
TEST(SvgStrokeWidth, TruncatedAttributeTerminates)
|
||||
{
|
||||
// A quote that is never closed must not over-read or hang.
|
||||
const QByteArray svg = R"(<svg><path stroke-width="2/></svg>)";
|
||||
|
||||
const QByteArray result = resolveSvgStrokeWidth(svg, 3.0);
|
||||
|
||||
EXPECT_TRUE(result.startsWith("<svg>"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
@ -5,12 +5,20 @@ cockatrice_add_test(
|
|||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_manager.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_badge.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_fill.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_ink.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_sticker.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp
|
||||
${VERSION_STRING_CPP}
|
||||
theme_migration_test.cpp
|
||||
theme_icon_render_test.cpp
|
||||
icon_tint_preview_test.cpp
|
||||
icon_treatment_preview_test.cpp
|
||||
LIBS
|
||||
libcockatrice_settings
|
||||
libcockatrice_network
|
||||
|
|
@ -18,4 +26,6 @@ cockatrice_add_test(
|
|||
libcockatrice_protocol
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||
DEFINITIONS
|
||||
COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
)
|
||||
|
|
|
|||
227
tests/theme_migration/icon_tint_preview_test.cpp
Normal file
227
tests/theme_migration/icon_tint_preview_test.cpp
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
#include "interface/theme_config.h"
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <QSize>
|
||||
#include <QStringList>
|
||||
#include <QStringLiteral>
|
||||
#include <QSvgRenderer>
|
||||
#include <gtest/gtest.h>
|
||||
#include <iterator>
|
||||
|
||||
namespace
|
||||
{
|
||||
/**
|
||||
* @brief Creates the GUI application QPixmap needs, using the offscreen plugin.
|
||||
*
|
||||
* The theme-migration binary is headless; creating this lazily keeps the other
|
||||
* test files free of a display dependency.
|
||||
*/
|
||||
void ensureGuiApplication()
|
||||
{
|
||||
static int argc = 1;
|
||||
static char name[] = "icon_tint_preview_test";
|
||||
static char *argv[] = {name, nullptr};
|
||||
// Leaked on purpose: a function-local static QApplication is destroyed before Qt's
|
||||
// own library statics at exit, and the network module's destructors then run
|
||||
// against a torn-down QObject layer (SEGV under AddressSanitizer).
|
||||
static QApplication *app = new QApplication(argc, argv);
|
||||
Q_UNUSED(app);
|
||||
}
|
||||
|
||||
QString resourcesDir()
|
||||
{
|
||||
return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources"));
|
||||
}
|
||||
|
||||
/** @brief Reads an authored icon from the theme resource directory. */
|
||||
QByteArray readIconSvg(const char *name)
|
||||
{
|
||||
QFile file(QDir(resourcesDir()).absoluteFilePath(QStringLiteral("icons/%1.svg").arg(QString::fromLatin1(name))));
|
||||
if (!file.open(QFile::ReadOnly)) {
|
||||
return {};
|
||||
}
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
/** @brief Renders a pixmap at device size and tags it with the ratio. */
|
||||
template <typename Render> QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render)
|
||||
{
|
||||
QPixmap pixmap = render(logicalSize * dpr);
|
||||
pixmap.setDevicePixelRatio(dpr);
|
||||
return pixmap;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief Writes one semantic-tint sheet per shipped palette theme.
|
||||
*
|
||||
* The theme files are the only place the shipped tones live, so each sheet is
|
||||
* rendered from the palette the theme actually ships: both semantic families,
|
||||
* three glyphs each, at the two sizes the UI uses, on the window and button
|
||||
* surfaces the contrast is measured against. A disabled row closes each family
|
||||
* so the enabled/disabled order is visible next to the tone that carries it.
|
||||
*/
|
||||
TEST(IconTintPreview, WritesOneSheetPerShippedTheme)
|
||||
{
|
||||
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
|
||||
if (outDir.isEmpty()) {
|
||||
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
|
||||
}
|
||||
ensureGuiApplication();
|
||||
ASSERT_TRUE(QDir().mkpath(outDir));
|
||||
|
||||
constexpr int MARGIN = 24;
|
||||
constexpr int LABEL_WIDTH = 190;
|
||||
constexpr int SLOT = 36;
|
||||
constexpr int ROW_HEIGHT = 56;
|
||||
constexpr int PANEL_TITLE = 26;
|
||||
constexpr int PANEL_PADDING = 12;
|
||||
constexpr qreal DPR = 2.0;
|
||||
constexpr int SIZES[] = {16, 24};
|
||||
|
||||
struct Family
|
||||
{
|
||||
const char *name;
|
||||
AppColor::Role role;
|
||||
const char *glyphs[3];
|
||||
};
|
||||
const QList<Family> families = {
|
||||
{"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}},
|
||||
{"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}},
|
||||
};
|
||||
const QStringList themes = {
|
||||
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),
|
||||
QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"),
|
||||
};
|
||||
|
||||
const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) {
|
||||
QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1()));
|
||||
QPixmap pixmap(size);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
renderer.render(&painter);
|
||||
return pixmap;
|
||||
};
|
||||
|
||||
const int glyphCount = static_cast<int>(std::size(families.first().glyphs));
|
||||
const int columnCount = glyphCount * static_cast<int>(std::size(SIZES));
|
||||
const int panelWidth = LABEL_WIDTH + columnCount * SLOT + PANEL_PADDING;
|
||||
const int panelHeight = PANEL_TITLE + static_cast<int>(families.size()) * 2 * ROW_HEIGHT + PANEL_PADDING;
|
||||
const int logicalWidth = 2 * MARGIN + panelWidth;
|
||||
const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN;
|
||||
|
||||
for (const QString &theme : themes) {
|
||||
for (const bool dark : {false, true}) {
|
||||
const QString file =
|
||||
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR))
|
||||
.absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml")
|
||||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
const PaletteConfig config = PaletteConfig::fromFile(file);
|
||||
ASSERT_TRUE(config.hasPalette()) << theme.toStdString();
|
||||
|
||||
const QPalette palette = config.apply(QPalette());
|
||||
const QColor window = palette.color(QPalette::Active, QPalette::Window);
|
||||
const QColor button = palette.color(QPalette::Active, QPalette::Button);
|
||||
const QColor text = palette.color(QPalette::Active, QPalette::WindowText);
|
||||
const QColor disabled = palette.color(QPalette::Disabled, QPalette::WindowText);
|
||||
|
||||
QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)),
|
||||
QImage::Format_ARGB32_Premultiplied);
|
||||
image.fill(window);
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.scale(DPR, DPR);
|
||||
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold));
|
||||
painter.drawText(QPointF(MARGIN, MARGIN),
|
||||
QStringLiteral("%1 — %2 scheme, semantic tints")
|
||||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
|
||||
struct Surface
|
||||
{
|
||||
const char *name;
|
||||
QColor background;
|
||||
};
|
||||
const QList<Surface> surfaces = {
|
||||
{"Window surface", window},
|
||||
{"Button surface", button},
|
||||
};
|
||||
|
||||
qreal panelTop = MARGIN + 30;
|
||||
for (const Surface &surface : surfaces) {
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(surface.background);
|
||||
painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8);
|
||||
|
||||
const qreal contentLeft = MARGIN + PANEL_PADDING;
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold));
|
||||
painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name));
|
||||
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 8));
|
||||
for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) {
|
||||
const qreal x = contentLeft + LABEL_WIDTH + static_cast<qreal>(sizeIndex) * glyphCount * SLOT;
|
||||
painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14),
|
||||
QStringLiteral("%1 px").arg(SIZES[sizeIndex]));
|
||||
}
|
||||
|
||||
qreal y = panelTop + PANEL_TITLE + 20;
|
||||
for (const Family &family : families) {
|
||||
const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark));
|
||||
ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name;
|
||||
for (int state = 0; state < 2; ++state) {
|
||||
const bool isDisabled = state == 1;
|
||||
const QColor ink = isDisabled ? disabled : tone;
|
||||
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold));
|
||||
painter.drawText(
|
||||
QPointF(contentLeft, y + 14),
|
||||
QStringLiteral("%1%2").arg(QString::fromLatin1(family.name),
|
||||
isDisabled ? QStringLiteral(" / disabled") : QString()));
|
||||
painter.setFont(QFont(QStringLiteral("Mono"), 7));
|
||||
painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb));
|
||||
|
||||
for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) {
|
||||
const int side = SIZES[sizeIndex];
|
||||
const QSize size(side, side);
|
||||
for (int glyphIndex = 0; glyphIndex < glyphCount; ++glyphIndex) {
|
||||
const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]);
|
||||
ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex];
|
||||
const QPixmap pixmap = renderAtDpr(
|
||||
size, DPR, [&](const QSize &pixelSize) { return renderLine(svg, ink, pixelSize); });
|
||||
const qreal x = contentLeft + LABEL_WIDTH +
|
||||
static_cast<qreal>(sizeIndex * glyphCount + glyphIndex) * SLOT +
|
||||
(SLOT - side) / 2.0;
|
||||
painter.drawPixmap(QPointF(x, y + (ROW_HEIGHT - side) / 2.0 - 2), pixmap);
|
||||
}
|
||||
}
|
||||
y += ROW_HEIGHT;
|
||||
}
|
||||
}
|
||||
panelTop += panelHeight + 24;
|
||||
}
|
||||
|
||||
painter.end();
|
||||
const QString path = QDir(outDir).absoluteFilePath(
|
||||
QStringLiteral("semantic-tint-preview-%1-%2.png")
|
||||
.arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||||
}
|
||||
}
|
||||
}
|
||||
248
tests/theme_migration/icon_treatment_preview_test.cpp
Normal file
248
tests/theme_migration/icon_treatment_preview_test.cpp
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
#include "interface/theme_config.h"
|
||||
#include "interface/theme_icon_badge.h"
|
||||
#include "interface/theme_icon_fill.h"
|
||||
#include "interface/theme_icon_sticker.h"
|
||||
#include "libcockatrice/utility/svg_recolor.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <QSize>
|
||||
#include <QStringList>
|
||||
#include <QStringLiteral>
|
||||
#include <QSvgRenderer>
|
||||
#include <gtest/gtest.h>
|
||||
#include <iterator>
|
||||
|
||||
namespace
|
||||
{
|
||||
/**
|
||||
* @brief Creates the GUI application QPixmap needs, using the offscreen plugin.
|
||||
*
|
||||
* The theme-migration binary is headless; creating this lazily keeps the other
|
||||
* test files free of a display dependency.
|
||||
*/
|
||||
void ensureGuiApplication()
|
||||
{
|
||||
static int argc = 1;
|
||||
static char name[] = "icon_treatment_preview_test";
|
||||
static char *argv[] = {name, nullptr};
|
||||
// Leaked on purpose: a function-local static QApplication is destroyed before Qt's
|
||||
// own library statics at exit, and the network module's destructors then run
|
||||
// against a torn-down QObject layer (SEGV under AddressSanitizer).
|
||||
static QApplication *app = new QApplication(argc, argv);
|
||||
Q_UNUSED(app);
|
||||
}
|
||||
|
||||
QString resourcesDir()
|
||||
{
|
||||
return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources"));
|
||||
}
|
||||
|
||||
/** @brief Reads a sentinel SVG from the shipped icon set. */
|
||||
QByteArray readIconSvg(const char *name)
|
||||
{
|
||||
QFile file(QDir(resourcesDir())
|
||||
.absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg")));
|
||||
return file.open(QFile::ReadOnly) ? file.readAll() : QByteArray();
|
||||
}
|
||||
|
||||
/** @brief Renders a pixmap at device size and tags it with the ratio. */
|
||||
template <typename Render> QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render)
|
||||
{
|
||||
QPixmap pixmap = render(logicalSize * dpr);
|
||||
pixmap.setDevicePixelRatio(dpr);
|
||||
return pixmap;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief Writes one badge/sticker sheet per shipped palette theme.
|
||||
*
|
||||
* The treatments are only useful if they read on every palette, so each sheet
|
||||
* is rendered from the palette the theme actually ships: both semantic
|
||||
* families, three glyphs each, as line glyphs, stickers and badges, at the two
|
||||
* sizes the UI uses, on the window and button surfaces the contrast is measured
|
||||
* against. A disabled row closes each family: every treatment falls back to the
|
||||
* line glyph there, so an unavailable control still reads as unavailable.
|
||||
*/
|
||||
TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme)
|
||||
{
|
||||
const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR");
|
||||
if (outDir.isEmpty()) {
|
||||
GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs";
|
||||
}
|
||||
ensureGuiApplication();
|
||||
ASSERT_TRUE(QDir().mkpath(outDir));
|
||||
|
||||
constexpr int MARGIN = 24;
|
||||
constexpr int LABEL_WIDTH = 190;
|
||||
constexpr int SLOT = 36;
|
||||
constexpr int TREATMENT_WIDTH = 120;
|
||||
constexpr int ROW_HEIGHT = 56;
|
||||
constexpr int PANEL_TITLE = 26;
|
||||
constexpr int COLUMN_HEADER = 20;
|
||||
constexpr int PANEL_PADDING = 12;
|
||||
constexpr qreal DPR = 2.0;
|
||||
constexpr int SIZES[] = {16, 24};
|
||||
constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"};
|
||||
|
||||
struct Family
|
||||
{
|
||||
const char *name;
|
||||
AppColor::Role role;
|
||||
const char *glyphs[3];
|
||||
};
|
||||
const QList<Family> families = {
|
||||
{"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}},
|
||||
{"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}},
|
||||
};
|
||||
const QStringList themes = {
|
||||
QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"),
|
||||
QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"),
|
||||
};
|
||||
|
||||
const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) {
|
||||
QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1()));
|
||||
QPixmap pixmap(size);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
renderer.render(&painter);
|
||||
return pixmap;
|
||||
};
|
||||
|
||||
const int treatmentCount = static_cast<int>(std::size(TREATMENTS));
|
||||
const int panelWidth = LABEL_WIDTH + treatmentCount * TREATMENT_WIDTH + PANEL_PADDING;
|
||||
const int panelHeight =
|
||||
PANEL_TITLE + COLUMN_HEADER + static_cast<int>(families.size()) * 2 * ROW_HEIGHT + PANEL_PADDING;
|
||||
const int logicalWidth = 2 * MARGIN + panelWidth;
|
||||
const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN;
|
||||
|
||||
for (const QString &theme : themes) {
|
||||
for (const bool dark : {false, true}) {
|
||||
const QString file =
|
||||
QDir(QStringLiteral(COCKATRICE_SOURCE_DIR))
|
||||
.absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml")
|
||||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
const PaletteConfig config = PaletteConfig::fromFile(file);
|
||||
ASSERT_TRUE(config.hasPalette()) << theme.toStdString();
|
||||
|
||||
const QPalette palette = config.apply(QPalette());
|
||||
const QColor window = palette.color(QPalette::Active, QPalette::Window);
|
||||
const QColor button = palette.color(QPalette::Active, QPalette::Button);
|
||||
const QColor text = palette.color(QPalette::Active, QPalette::WindowText);
|
||||
const QColor disabled = palette.color(QPalette::Disabled, QPalette::WindowText);
|
||||
|
||||
QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)),
|
||||
QImage::Format_ARGB32_Premultiplied);
|
||||
image.fill(window);
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.scale(DPR, DPR);
|
||||
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold));
|
||||
painter.drawText(QPointF(MARGIN, MARGIN),
|
||||
QStringLiteral("%1 — %2 scheme, treatments across glyphs")
|
||||
.arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
|
||||
struct Surface
|
||||
{
|
||||
const char *name;
|
||||
QColor background;
|
||||
};
|
||||
const QList<Surface> surfaces = {
|
||||
{"Window surface", window},
|
||||
{"Button surface", button},
|
||||
};
|
||||
|
||||
qreal panelTop = MARGIN + 30;
|
||||
for (const Surface &surface : surfaces) {
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(surface.background);
|
||||
painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8);
|
||||
|
||||
const qreal contentLeft = MARGIN + PANEL_PADDING;
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold));
|
||||
painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name));
|
||||
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 8));
|
||||
for (int treatment = 0; treatment < treatmentCount; ++treatment) {
|
||||
const qreal x = contentLeft + LABEL_WIDTH + static_cast<qreal>(treatment) * TREATMENT_WIDTH;
|
||||
painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14),
|
||||
QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24"));
|
||||
}
|
||||
|
||||
qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER;
|
||||
for (const Family &family : families) {
|
||||
// The authored role is the tone the filled shape carries; the
|
||||
// treatments apply their own fill rule from it.
|
||||
const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark));
|
||||
ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name;
|
||||
|
||||
for (int state = 0; state < 2; ++state) {
|
||||
const bool isDisabled = state == 1;
|
||||
const QColor ink = isDisabled ? disabled : tone;
|
||||
|
||||
painter.setPen(text);
|
||||
painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold));
|
||||
painter.drawText(
|
||||
QPointF(contentLeft, y + 14),
|
||||
QStringLiteral("%1%2").arg(QString::fromLatin1(family.name),
|
||||
isDisabled ? QStringLiteral(" / disabled") : QString()));
|
||||
painter.setFont(QFont(QStringLiteral("Mono"), 7));
|
||||
painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb));
|
||||
|
||||
for (int treatment = 0; treatment < treatmentCount; ++treatment) {
|
||||
const qreal cellX =
|
||||
contentLeft + LABEL_WIDTH + static_cast<qreal>(treatment) * TREATMENT_WIDTH;
|
||||
for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) {
|
||||
const int side = SIZES[sizeIndex];
|
||||
const QSize size(side, side);
|
||||
const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28);
|
||||
for (int glyphIndex = 0; glyphIndex < 3; ++glyphIndex) {
|
||||
const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]);
|
||||
ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex];
|
||||
const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) {
|
||||
if (isDisabled || treatment == 0) {
|
||||
return renderLine(svg, ink, pixelSize);
|
||||
}
|
||||
if (treatment == 1) {
|
||||
return ThemeIconSticker::renderSticker(svg, ink, pixelSize);
|
||||
}
|
||||
return ThemeIconBadge::renderBadge(svg, ink, pixelSize,
|
||||
dark ? ThemeIconFill::Scheme::Dark
|
||||
: ThemeIconFill::Scheme::Light);
|
||||
});
|
||||
const qreal x =
|
||||
cellX + 6 + static_cast<qreal>(glyphIndex) * SLOT + (SLOT - side) / 2.0;
|
||||
painter.drawPixmap(QPointF(x, rowTop + (SLOT - side) / 2.0), pixmap);
|
||||
}
|
||||
}
|
||||
}
|
||||
y += ROW_HEIGHT;
|
||||
}
|
||||
}
|
||||
panelTop += panelHeight + 24;
|
||||
}
|
||||
|
||||
painter.end();
|
||||
const QString path = QDir(outDir).absoluteFilePath(
|
||||
QStringLiteral("treatment-preview-%1-%2.png")
|
||||
.arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light")));
|
||||
ASSERT_TRUE(image.save(path)) << path.toStdString();
|
||||
}
|
||||
}
|
||||
}
|
||||
2242
tests/theme_migration/theme_icon_render_test.cpp
Normal file
2242
tests/theme_migration/theme_icon_render_test.cpp
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,12 +9,16 @@
|
|||
#include <QStringLiteral>
|
||||
#include <QTemporaryDir>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
#include <string>
|
||||
|
||||
// Owned by main.cpp in the real application; pixel_map_generator.cpp references
|
||||
// it, so the test binary needs a definition even though it never renders.
|
||||
ThemeManager *themeManager = nullptr;
|
||||
|
||||
// Referenced by other translation units in the server_remote library.
|
||||
RNG_Abstract *rng = nullptr;
|
||||
|
||||
namespace
|
||||
{
|
||||
const QString LEGACY = QStringLiteral("Default");
|
||||
|
|
@ -149,6 +153,66 @@ TEST_F(ThemeMigrationTest, IsIdempotent)
|
|||
EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("css"));
|
||||
}
|
||||
|
||||
TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme)
|
||||
{
|
||||
EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1a7d3a")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, false), QColor(QStringLiteral("#007e28")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#32c05c")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#4c7659")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#8ab196")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, false), QColor(QStringLiteral("#da0b00")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ff8378")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#935f5c")));
|
||||
EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#c79e9b")));
|
||||
// Accent and neutral icon roles have no static default: an older theme
|
||||
// written before the roles existed falls back to deriving them from its
|
||||
// palette rather than pinning one scheme's ink.
|
||||
for (const bool dark : {false, true}) {
|
||||
EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, dark).isValid());
|
||||
EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, dark).isValid());
|
||||
EXPECT_FALSE(defaultAppColor(AppColor::Icon, dark).isValid());
|
||||
EXPECT_FALSE(defaultAppColor(AppColor::IconStrong, dark).isValid());
|
||||
EXPECT_FALSE(defaultAppColor(AppColor::IconSoft, dark).isValid());
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles)
|
||||
{
|
||||
QTemporaryDir dir;
|
||||
ASSERT_TRUE(dir.isValid());
|
||||
|
||||
PaletteConfig config;
|
||||
const QMap<AppColor::Role, QColor> roles = {
|
||||
{AppColor::Icon, QColor(QStringLiteral("#2f59ad"))},
|
||||
{AppColor::IconStrong, QColor(QStringLiteral("#213e79"))},
|
||||
{AppColor::IconSoft, QColor(QStringLiteral("#687899"))},
|
||||
{AppColor::Positive, QColor(QStringLiteral("#1b7f3b"))},
|
||||
{AppColor::PositiveStrong, QColor(QStringLiteral("#104a22"))},
|
||||
{AppColor::PositiveSoft, QColor(QStringLiteral("#5d7f6f"))},
|
||||
{AppColor::Negative, QColor(QStringLiteral("#b3261e"))},
|
||||
{AppColor::NegativeStrong, QColor(QStringLiteral("#7c1b15"))},
|
||||
{AppColor::NegativeSoft, QColor(QStringLiteral("#9a6c6f"))},
|
||||
};
|
||||
for (auto it = roles.cbegin(); it != roles.cend(); ++it) {
|
||||
config.appColors[it.key()] = it.value();
|
||||
}
|
||||
|
||||
const QString path = QDir(dir.path()).absoluteFilePath(QStringLiteral("palette-light.toml"));
|
||||
QFile file(path);
|
||||
ASSERT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
file.write(config.toToml().toUtf8());
|
||||
file.close();
|
||||
|
||||
const PaletteConfig parsed = PaletteConfig::fromFile(path);
|
||||
for (auto it = roles.cbegin(); it != roles.cend(); ++it) {
|
||||
ASSERT_TRUE(parsed.appColors.contains(it.key()));
|
||||
EXPECT_EQ(parsed.appColors.value(it.key()), it.value());
|
||||
}
|
||||
}
|
||||
|
||||
// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and
|
||||
// the fallback then links the bare gtest library without gtest_main.
|
||||
int main(int argc, char **argv)
|
||||
|
|
|
|||
248
tests/user_games_model_test.cpp
Normal file
248
tests/user_games_model_test.cpp
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
#include "interface/widgets/server/user/user_games_model.h"
|
||||
|
||||
#include <QAbstractItemModelTester>
|
||||
#include <QCoreApplication>
|
||||
#include <QPersistentModelIndex>
|
||||
#include <QSignalSpy>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
#include <libcockatrice/protocol/pb/commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
|
||||
#include <libcockatrice/protocol/pb/server_message.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
#include <memory>
|
||||
|
||||
RNG_Abstract *rng = nullptr; // required by the transitive server library; this suite sends no game commands
|
||||
|
||||
namespace
|
||||
{
|
||||
class GamesClient : public AbstractClient
|
||||
{
|
||||
public:
|
||||
QList<CommandContainer> sent;
|
||||
|
||||
GamesClient()
|
||||
{
|
||||
setStatus(StatusLoggedIn);
|
||||
}
|
||||
|
||||
~GamesClient() override
|
||||
{
|
||||
qDeleteAll(pendingCommands);
|
||||
pendingCommands.clear();
|
||||
}
|
||||
|
||||
void changeStatus(ClientStatus status)
|
||||
{
|
||||
setStatus(status);
|
||||
}
|
||||
|
||||
void answer(int request, Response response)
|
||||
{
|
||||
response.set_cmd_id(sent.at(request).cmd_id());
|
||||
ServerMessage message;
|
||||
message.set_message_type(ServerMessage::RESPONSE);
|
||||
*message.mutable_response() = response;
|
||||
processProtocolItem(message);
|
||||
}
|
||||
|
||||
protected:
|
||||
void sendCommandContainer(const CommandContainer &command) override
|
||||
{
|
||||
sent.append(command);
|
||||
}
|
||||
};
|
||||
|
||||
Response gamesResponse(std::initializer_list<int> ids)
|
||||
{
|
||||
Response response;
|
||||
response.set_response_code(Response::RespOk);
|
||||
auto *games = response.MutableExtension(Response_GetGamesOfUser::ext);
|
||||
for (int id : ids) {
|
||||
auto *game = games->add_game_list();
|
||||
game->set_game_id(id);
|
||||
game->set_room_id(7);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
using State = UserGamesModel::State;
|
||||
|
||||
TEST(UserGamesModel, OfflineUserDoesNotSendARequest)
|
||||
{
|
||||
GamesClient client;
|
||||
UserGamesModel model(&client);
|
||||
model.setUser("Alice", false);
|
||||
model.refresh();
|
||||
EXPECT_TRUE(client.sent.isEmpty());
|
||||
EXPECT_EQ(State::Offline, model.getState());
|
||||
EXPECT_FALSE(model.canRefresh());
|
||||
EXPECT_EQ(0, model.rowCount());
|
||||
}
|
||||
|
||||
TEST(UserGamesModel, PresenceLossInvalidatesTheOutstandingResponse)
|
||||
{
|
||||
GamesClient client;
|
||||
UserGamesModel model(&client);
|
||||
model.setUser("Alice", true);
|
||||
EXPECT_EQ(State::Loading, model.getState());
|
||||
EXPECT_FALSE(model.canRefresh());
|
||||
model.setUser("Alice", false);
|
||||
client.answer(0, gamesResponse({1}));
|
||||
EXPECT_EQ(State::Offline, model.getState());
|
||||
EXPECT_EQ(0, model.rowCount());
|
||||
|
||||
model.setUser("Alice", true);
|
||||
client.answer(1, gamesResponse({2}));
|
||||
EXPECT_EQ(State::Ready, model.getState());
|
||||
EXPECT_TRUE(model.canRefresh());
|
||||
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
|
||||
}
|
||||
|
||||
TEST(UserGamesModel, SwitchingBackToTheSameUserDoesNotAcceptTheirOldRequest)
|
||||
{
|
||||
GamesClient client;
|
||||
UserGamesModel model(&client);
|
||||
model.setUser("Alice", true);
|
||||
model.setUser("Bob", true);
|
||||
model.setUser("Alice", true);
|
||||
client.answer(2, gamesResponse({3}));
|
||||
client.answer(0, gamesResponse({1}));
|
||||
client.answer(1, gamesResponse({2}));
|
||||
EXPECT_EQ(1, model.rowCount());
|
||||
EXPECT_EQ(3, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
|
||||
}
|
||||
|
||||
TEST(UserGamesModel, RefreshSupersedesThePreviousRequest)
|
||||
{
|
||||
GamesClient client;
|
||||
UserGamesModel model(&client);
|
||||
model.setUser("Alice", true);
|
||||
model.refresh();
|
||||
client.answer(0, gamesResponse({1}));
|
||||
EXPECT_EQ(State::Loading, model.getState());
|
||||
EXPECT_EQ(0, model.rowCount());
|
||||
client.answer(1, gamesResponse({2}));
|
||||
EXPECT_EQ(State::Ready, model.getState());
|
||||
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
|
||||
}
|
||||
|
||||
TEST(UserGamesModel, EmptySuccessAndMissingPayloadAreDifferentStates)
|
||||
{
|
||||
GamesClient client;
|
||||
UserGamesModel model(&client);
|
||||
model.setUser("Alice", true);
|
||||
client.answer(0, gamesResponse({}));
|
||||
EXPECT_EQ(State::Empty, model.getState());
|
||||
EXPECT_TRUE(model.canRefresh());
|
||||
|
||||
model.refresh();
|
||||
Response missingPayload;
|
||||
missingPayload.set_response_code(Response::RespOk);
|
||||
client.answer(1, missingPayload);
|
||||
EXPECT_EQ(State::Error, model.getState());
|
||||
EXPECT_TRUE(model.canRefresh());
|
||||
}
|
||||
|
||||
TEST(UserGamesModel, FailedRequestsCanBeRetried)
|
||||
{
|
||||
GamesClient client;
|
||||
UserGamesModel model(&client);
|
||||
model.setUser("Alice", true);
|
||||
Response error;
|
||||
error.set_response_code(Response::RespNameNotFound);
|
||||
client.answer(0, error);
|
||||
EXPECT_EQ(State::Error, model.getState());
|
||||
model.refresh();
|
||||
client.answer(1, gamesResponse({4}));
|
||||
EXPECT_EQ(State::Ready, model.getState());
|
||||
}
|
||||
|
||||
TEST(UserGamesModel, DisconnectRejectsLateRepliesAndReconnectFetchesAgain)
|
||||
{
|
||||
GamesClient client;
|
||||
UserGamesModel model(&client);
|
||||
model.setUser("Alice", true);
|
||||
client.changeStatus(StatusDisconnecting);
|
||||
client.answer(0, gamesResponse({1}));
|
||||
EXPECT_EQ(State::Disconnected, model.getState());
|
||||
EXPECT_FALSE(model.canRefresh());
|
||||
model.refresh();
|
||||
EXPECT_EQ(1, client.sent.size());
|
||||
client.changeStatus(StatusLoggedIn);
|
||||
client.answer(1, gamesResponse({2}));
|
||||
EXPECT_EQ(State::Ready, model.getState());
|
||||
}
|
||||
|
||||
TEST(UserGamesModel, ClientDestructionClearsGamesAndDisablesRefresh)
|
||||
{
|
||||
auto client = std::make_unique<GamesClient>();
|
||||
UserGamesModel model(client.get());
|
||||
model.setUser("Alice", true);
|
||||
client->answer(0, gamesResponse({1}));
|
||||
client.reset();
|
||||
EXPECT_EQ(State::Disconnected, model.getState());
|
||||
EXPECT_EQ(0, model.rowCount());
|
||||
EXPECT_FALSE(model.canRefresh());
|
||||
model.refresh();
|
||||
EXPECT_EQ(State::Disconnected, model.getState());
|
||||
}
|
||||
|
||||
TEST(UserGamesModel, ResetInvalidatesAnOpenGameActionAndPreservesTheModelContract)
|
||||
{
|
||||
GamesClient client;
|
||||
UserGamesModel model(&client);
|
||||
QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::Fatal);
|
||||
model.setUser("Alice", true);
|
||||
client.answer(0, gamesResponse({1, 2, 3}));
|
||||
QPersistentModelIndex selected(model.index(1));
|
||||
EXPECT_TRUE(selected.isValid());
|
||||
model.refresh();
|
||||
EXPECT_FALSE(selected.isValid());
|
||||
EXPECT_EQ(0, model.rowCount());
|
||||
client.answer(1, gamesResponse({4}));
|
||||
EXPECT_EQ(1, model.rowCount());
|
||||
}
|
||||
|
||||
TEST(UserGamesModel, StateListenerCanSelectAnotherUserBeforeTheRequestIsSent)
|
||||
{
|
||||
GamesClient client;
|
||||
UserGamesModel model(&client);
|
||||
bool switched = false;
|
||||
QObject::connect(&model, &UserGamesModel::stateChanged, &model, [&] {
|
||||
if (!switched && model.getState() == State::Loading) {
|
||||
switched = true;
|
||||
model.setUser("Bob", false);
|
||||
}
|
||||
});
|
||||
model.setUser("Alice", true);
|
||||
EXPECT_TRUE(client.sent.isEmpty());
|
||||
EXPECT_EQ(State::Offline, model.getState());
|
||||
}
|
||||
|
||||
TEST(UserGamesModel, TimedOutResponseCannotReplaceARetry)
|
||||
{
|
||||
GamesClient client;
|
||||
UserGamesModel model(&client, 50);
|
||||
model.setUser("Alice", true);
|
||||
QSignalSpy changes(&model, &UserGamesModel::stateChanged);
|
||||
ASSERT_TRUE(changes.wait(1000));
|
||||
ASSERT_EQ(State::Error, model.getState());
|
||||
EXPECT_TRUE(model.canRefresh());
|
||||
model.refresh();
|
||||
client.answer(0, gamesResponse({1}));
|
||||
EXPECT_EQ(State::Loading, model.getState());
|
||||
client.answer(1, gamesResponse({2}));
|
||||
EXPECT_EQ(State::Ready, model.getState());
|
||||
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue