mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-21 09:05:10 -07:00
Merge branch 'master' into tooomm-patch-33
This commit is contained in:
commit
860ff503b1
233 changed files with 7900 additions and 1711 deletions
|
|
@ -4,7 +4,7 @@ enable_testing()
|
|||
|
||||
add_test(NAME dummy_test COMMAND dummy_test)
|
||||
add_executable(dummy_test dummy_test.cpp)
|
||||
# Add timeout to prevent hanging if there is any issue with the general GTest setup
|
||||
# Add timeout to prevent hanging in case of issues with the general GTest setup
|
||||
set_tests_properties(dummy_test PROPERTIES TIMEOUT 5)
|
||||
|
||||
add_test(NAME clamped_arithmetic_test COMMAND clamped_arithmetic_test)
|
||||
|
|
@ -24,6 +24,9 @@ target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatri
|
|||
add_test(NAME latency_tracker_test COMMAND latency_tracker_test)
|
||||
add_executable(latency_tracker_test latency_tracker_test.cpp)
|
||||
|
||||
add_test(NAME metrics_registry_test COMMAND metrics_registry_test)
|
||||
add_executable(metrics_registry_test ../servatrice/src/metrics_registry.cpp metrics_registry_test.cpp)
|
||||
|
||||
add_test(NAME password_hash_test COMMAND password_hash_test)
|
||||
add_executable(password_hash_test password_hash_test.cpp)
|
||||
|
||||
|
|
@ -36,6 +39,9 @@ add_executable(server_card_counter_test server_card_counter_test.cpp)
|
|||
add_test(NAME server_counter_test COMMAND server_counter_test)
|
||||
add_executable(server_counter_test server_counter_test.cpp)
|
||||
|
||||
add_test(NAME server_developer_role_test COMMAND server_developer_role_test)
|
||||
add_executable(server_developer_role_test server_developer_role_test.cpp)
|
||||
|
||||
add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test)
|
||||
add_executable(server_rate_limiter_test server_rate_limiter_test.cpp)
|
||||
|
||||
|
|
@ -81,9 +87,11 @@ if(NOT GTEST_FOUND)
|
|||
add_dependencies(server_card_counter_test gtest)
|
||||
add_dependencies(server_counter_test gtest)
|
||||
add_dependencies(server_rate_limiter_test gtest)
|
||||
add_dependencies(server_developer_role_test gtest)
|
||||
add_dependencies(warning_categories_test gtest)
|
||||
add_dependencies(lag_monitor_test gtest)
|
||||
add_dependencies(latency_tracker_test gtest)
|
||||
add_dependencies(metrics_registry_test gtest)
|
||||
endif()
|
||||
|
||||
include_directories(${GTEST_INCLUDE_DIRS})
|
||||
|
|
@ -115,6 +123,10 @@ target_link_libraries(
|
|||
target_link_libraries(
|
||||
server_rate_limiter_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
|
||||
)
|
||||
target_link_libraries(
|
||||
server_developer_role_test libcockatrice_network libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES}
|
||||
${TEST_QT_MODULES}
|
||||
)
|
||||
target_link_libraries(
|
||||
warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
|
||||
)
|
||||
|
|
@ -122,9 +134,12 @@ target_link_libraries(lag_monitor_test Threads::Threads ${GTEST_BOTH_LIBRARIES}
|
|||
target_link_libraries(
|
||||
latency_tracker_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
|
||||
)
|
||||
target_include_directories(metrics_registry_test PRIVATE ${CMAKE_SOURCE_DIR}/servatrice/src)
|
||||
target_link_libraries(metrics_registry_test ${TEST_QT_MODULES} Threads::Threads ${GTEST_BOTH_LIBRARIES})
|
||||
|
||||
add_subdirectory(card_zone_algorithms)
|
||||
add_subdirectory(carddatabase)
|
||||
add_subdirectory(deck_list_model)
|
||||
add_subdirectory(deck_list_zones)
|
||||
add_subdirectory(loading_from_clipboard)
|
||||
add_subdirectory(movecard_tests)
|
||||
|
|
|
|||
|
|
@ -134,6 +134,35 @@ TEST_F(AddCardAlgorithmTest, MidListInsertionPreservesOrder)
|
|||
EXPECT_EQ(knownList.at(2), &b);
|
||||
}
|
||||
|
||||
// Reconnecting to a game rebuilds zones from a ServerInfo_Zone. Non-coordinate zones
|
||||
// (hand, piles, stack) report x == 0 on every card, so inserting each rebuilt card at
|
||||
// that index would reverse the received server order. Appending (-1) keeps it.
|
||||
TEST_F(AddCardAlgorithmTest, RebuildInsertAtZeroReversesServerOrder)
|
||||
{
|
||||
MockCard a, b, c;
|
||||
CardZoneAlgorithms::addCardToList(knownList, &a, 0, false);
|
||||
CardZoneAlgorithms::addCardToList(knownList, &b, 0, false);
|
||||
CardZoneAlgorithms::addCardToList(knownList, &c, 0, false);
|
||||
|
||||
EXPECT_EQ(knownList.size(), 3);
|
||||
EXPECT_EQ(knownList.at(0), &c);
|
||||
EXPECT_EQ(knownList.at(1), &b);
|
||||
EXPECT_EQ(knownList.at(2), &a);
|
||||
}
|
||||
|
||||
TEST_F(AddCardAlgorithmTest, RebuildAppendPreservesServerOrder)
|
||||
{
|
||||
MockCard a, b, c;
|
||||
CardZoneAlgorithms::addCardToList(knownList, &a, -1, false);
|
||||
CardZoneAlgorithms::addCardToList(knownList, &b, -1, false);
|
||||
CardZoneAlgorithms::addCardToList(knownList, &c, -1, false);
|
||||
|
||||
EXPECT_EQ(knownList.size(), 3);
|
||||
EXPECT_EQ(knownList.at(0), &a);
|
||||
EXPECT_EQ(knownList.at(1), &b);
|
||||
EXPECT_EQ(knownList.at(2), &c);
|
||||
}
|
||||
|
||||
TEST_F(AddCardAlgorithmTest, KeepAnnotationsFalsePassedThrough)
|
||||
{
|
||||
MockCard card;
|
||||
|
|
|
|||
33
tests/deck_list_model/CMakeLists.txt
Normal file
33
tests/deck_list_model/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
add_executable(deck_list_model_custom_zones_test ${VERSION_STRING_CPP} deck_list_model_custom_zones_test.cpp)
|
||||
|
||||
if(NOT GTEST_FOUND)
|
||||
add_dependencies(deck_list_model_custom_zones_test gtest)
|
||||
endif()
|
||||
|
||||
target_link_libraries(
|
||||
deck_list_model_custom_zones_test
|
||||
libcockatrice_models
|
||||
libcockatrice_card
|
||||
libcockatrice_deck_list
|
||||
Threads::Threads
|
||||
${GTEST_BOTH_LIBRARIES}
|
||||
${TEST_QT_MODULES}
|
||||
)
|
||||
add_test(NAME deck_list_model_custom_zones_test COMMAND deck_list_model_custom_zones_test)
|
||||
|
||||
add_executable(deck_list_model_zone_integration_test ${VERSION_STRING_CPP} deck_list_model_zone_integration_test.cpp)
|
||||
|
||||
if(NOT GTEST_FOUND)
|
||||
add_dependencies(deck_list_model_zone_integration_test gtest)
|
||||
endif()
|
||||
|
||||
target_link_libraries(
|
||||
deck_list_model_zone_integration_test
|
||||
libcockatrice_models
|
||||
libcockatrice_card
|
||||
libcockatrice_deck_list
|
||||
Threads::Threads
|
||||
${GTEST_BOTH_LIBRARIES}
|
||||
${TEST_QT_MODULES}
|
||||
)
|
||||
add_test(NAME deck_list_model_zone_integration_test COMMAND deck_list_model_zone_integration_test)
|
||||
276
tests/deck_list_model/deck_list_model_custom_zones_test.cpp
Normal file
276
tests/deck_list_model/deck_list_model_custom_zones_test.cpp
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* @file deck_list_model_custom_zones_test.cpp
|
||||
* @brief Tests for the deck list model's custom-zone shadow-tree helpers.
|
||||
*
|
||||
* DeckListModelCustomZones centralizes every "what is / where is a custom zone"
|
||||
* decision for the model's shadow tree: type testing, mirroring from the deck
|
||||
* tree, name lookup, and the sort-with-custom-zones-last ordering. These tests
|
||||
* exercise that logic directly on hand-built shadow trees, independent of the
|
||||
* full model and card database machinery.
|
||||
*/
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
DecklistModelCardNode *cardNode(InnerDecklistNode *parent, const QString &name, int number)
|
||||
{
|
||||
// The underlying data node is detached; only the model wrapper is attached to the shadow tree.
|
||||
auto *data = new DecklistCardNode(name, number, nullptr);
|
||||
return new DecklistModelCardNode(data, parent);
|
||||
}
|
||||
|
||||
QStringList childNames(const InnerDecklistNode *node)
|
||||
{
|
||||
QStringList names;
|
||||
for (int i = 0; i < node->size(); ++i) {
|
||||
names.append(node->at(i)->getName());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// =====================================================================================================================
|
||||
// isCustomZone
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckListModelCustomZones, IsCustomZoneDistinguishesZoneFromGroup)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
auto *group = new InnerDecklistNode("Creature", board);
|
||||
auto *zone = new DecklistModelSubZoneNode("Removal", board);
|
||||
|
||||
auto *card = cardNode(group, "A", 1);
|
||||
|
||||
EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(board));
|
||||
EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(group));
|
||||
EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(card));
|
||||
EXPECT_TRUE(DeckListModelCustomZones::isCustomZone(zone));
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
// findSubZoneByName
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckListModelCustomZones, FindSubZoneByNameFindsAcrossBoards)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *main = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
auto *side = new InnerDecklistNode(DECK_ZONE_SIDE, &root);
|
||||
new DecklistModelSubZoneNode("Removal", main);
|
||||
new DecklistModelSubZoneNode("Utility", side);
|
||||
new InnerDecklistNode("Plain", main); // not a custom zone
|
||||
|
||||
auto *removal = DeckListModelCustomZones::findSubZoneByName(&root, "Removal");
|
||||
ASSERT_NE(removal, nullptr);
|
||||
EXPECT_EQ(removal->getName(), QString("Removal"));
|
||||
|
||||
auto *utility = DeckListModelCustomZones::findSubZoneByName(&root, "Utility");
|
||||
ASSERT_NE(utility, nullptr);
|
||||
EXPECT_EQ(utility->getName(), QString("Utility"));
|
||||
|
||||
// Names are deck-unique; a plain group or built-in board is not matched.
|
||||
EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Plain"), nullptr);
|
||||
EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, DECK_ZONE_MAIN), nullptr);
|
||||
EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Missing"), nullptr);
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
// mirrorCustomZones
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckListModelCustomZones, MirrorCustomZonesCopiesCardsFlat)
|
||||
{
|
||||
// Deck-tree board zone: one direct card plus one nested custom zone.
|
||||
auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN);
|
||||
new DecklistCardNode("Direct", 2, deckBoard);
|
||||
|
||||
auto *deckZone = new InnerDecklistNode("Removal", deckBoard);
|
||||
auto *deckCard1 = new DecklistCardNode("Bolt", 3, deckZone);
|
||||
auto *deckCard2 = new DecklistCardNode("Swords", 1, deckZone);
|
||||
|
||||
InnerDecklistNode shadowRoot;
|
||||
auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot);
|
||||
|
||||
DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard);
|
||||
|
||||
// Only the custom zone is mirrored as a sub-zone; the direct card is not.
|
||||
ASSERT_EQ(shadowBoard->size(), 1);
|
||||
auto *shadowZone = dynamic_cast<DecklistModelSubZoneNode *>(shadowBoard->at(0));
|
||||
ASSERT_NE(shadowZone, nullptr);
|
||||
EXPECT_EQ(shadowZone->getName(), QString("Removal"));
|
||||
|
||||
// Cards live flat (un-grouped) inside the mirrored zone, wrapping the same data nodes.
|
||||
ASSERT_EQ(shadowZone->size(), 2);
|
||||
auto *shadowCard1 = dynamic_cast<DecklistModelCardNode *>(shadowZone->at(0));
|
||||
auto *shadowCard2 = dynamic_cast<DecklistModelCardNode *>(shadowZone->at(1));
|
||||
ASSERT_NE(shadowCard1, nullptr);
|
||||
ASSERT_NE(shadowCard2, nullptr);
|
||||
EXPECT_EQ(shadowCard1->getDataNode(), deckCard1);
|
||||
EXPECT_EQ(shadowCard2->getDataNode(), deckCard2);
|
||||
}
|
||||
|
||||
TEST(DeckListModelCustomZones, MirrorCustomZonesWithNoCustomZonesIsNoop)
|
||||
{
|
||||
// A board zone with only direct cards has nothing to mirror.
|
||||
auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN);
|
||||
new DecklistCardNode("Direct", 2, deckBoard);
|
||||
|
||||
InnerDecklistNode shadowRoot;
|
||||
auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot);
|
||||
|
||||
DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard);
|
||||
EXPECT_EQ(shadowBoard->size(), 0);
|
||||
}
|
||||
|
||||
TEST(DeckListModelCustomZones, MirrorCustomZonesFlattensNestedSubzones)
|
||||
{
|
||||
// Cards deeper than one level under a custom zone still get a model row.
|
||||
auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN);
|
||||
auto *deckZone = new InnerDecklistNode("Removal", deckBoard);
|
||||
auto *deckCard1 = new DecklistCardNode("Bolt", 1, deckZone);
|
||||
auto *deeper = new InnerDecklistNode("Deeper", deckZone);
|
||||
auto *deckCard2 = new DecklistCardNode("Swords", 1, deeper);
|
||||
|
||||
InnerDecklistNode shadowRoot;
|
||||
auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot);
|
||||
|
||||
DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard);
|
||||
|
||||
ASSERT_EQ(shadowBoard->size(), 1);
|
||||
auto *shadowZone = dynamic_cast<DecklistModelSubZoneNode *>(shadowBoard->at(0));
|
||||
ASSERT_NE(shadowZone, nullptr);
|
||||
EXPECT_EQ(shadowZone->getName(), QString("Removal"));
|
||||
|
||||
// Both cards are flattened into the mirrored zone, preserving order.
|
||||
ASSERT_EQ(shadowZone->size(), 2);
|
||||
auto *shadowCard1 = dynamic_cast<DecklistModelCardNode *>(shadowZone->at(0));
|
||||
auto *shadowCard2 = dynamic_cast<DecklistModelCardNode *>(shadowZone->at(1));
|
||||
ASSERT_NE(shadowCard1, nullptr);
|
||||
ASSERT_NE(shadowCard2, nullptr);
|
||||
EXPECT_EQ(shadowCard1->getDataNode(), deckCard1);
|
||||
EXPECT_EQ(shadowCard2->getDataNode(), deckCard2);
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
// findGroupChild
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckListModelCustomZones, FindGroupChildSkipsCustomZones)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
auto *group = new InnerDecklistNode("Creature", board);
|
||||
new DecklistModelSubZoneNode("Creature", board);
|
||||
|
||||
EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Creature"), group);
|
||||
EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Missing"), nullptr);
|
||||
EXPECT_EQ(DeckListModelCustomZones::findGroupChild(&root, DECK_ZONE_MAIN), board);
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
// sortWithCustomZonesLast
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsAscending)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
new DecklistModelSubZoneNode("Zebra", board);
|
||||
new InnerDecklistNode("Creature", board);
|
||||
new InnerDecklistNode("Instant", board);
|
||||
new DecklistModelSubZoneNode("Alpha", board);
|
||||
|
||||
root.setSortMethod(DeckSortMethod::ByName);
|
||||
|
||||
auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder);
|
||||
|
||||
// Groups sort first (by name), then custom zones (by name), always after groups.
|
||||
EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"}));
|
||||
|
||||
// Some non-identity movement occurred.
|
||||
EXPECT_FALSE(mapping.isEmpty());
|
||||
}
|
||||
|
||||
TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsDescending)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
new DecklistModelSubZoneNode("Zebra", board);
|
||||
new InnerDecklistNode("Creature", board);
|
||||
new InnerDecklistNode("Instant", board);
|
||||
new DecklistModelSubZoneNode("Alpha", board);
|
||||
|
||||
root.setSortMethod(DeckSortMethod::ByName);
|
||||
|
||||
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder);
|
||||
|
||||
// Groups still lead (descending), custom zones still last.
|
||||
EXPECT_EQ(childNames(board), (QStringList{"Instant", "Creature", "Zebra", "Alpha"}));
|
||||
}
|
||||
|
||||
TEST(DeckListModelCustomZones, SortBoardMappingIsConsistent)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
|
||||
QList<AbstractDecklistNode *> originalOrder;
|
||||
auto *g0 = new InnerDecklistNode("Creature", board);
|
||||
originalOrder.append(g0);
|
||||
auto *z0 = new DecklistModelSubZoneNode("Zebra", board);
|
||||
originalOrder.append(z0);
|
||||
auto *g1 = new InnerDecklistNode("Instant", board);
|
||||
originalOrder.append(g1);
|
||||
auto *z1 = new DecklistModelSubZoneNode("Alpha", board);
|
||||
originalOrder.append(z1);
|
||||
|
||||
root.setSortMethod(DeckSortMethod::ByName);
|
||||
|
||||
auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder);
|
||||
|
||||
// The mapping reports, for each final row, the original row of the node now sitting there.
|
||||
ASSERT_EQ(mapping.size(), board->size());
|
||||
for (const auto &move : mapping) {
|
||||
const int preSortRow = move.first;
|
||||
const int finalRow = move.second;
|
||||
ASSERT_GE(preSortRow, 0);
|
||||
ASSERT_LT(preSortRow, originalOrder.size());
|
||||
EXPECT_EQ(board->at(finalRow), originalOrder[preSortRow]) << "row " << finalRow;
|
||||
}
|
||||
|
||||
// Final order sanity: groups first in name order, then custom zones.
|
||||
EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"}));
|
||||
}
|
||||
|
||||
TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones)
|
||||
{
|
||||
// A non-board node (e.g. a group whose children are cards) is sorted plainly;
|
||||
// custom zones are not a special case there. Cards sort by name.
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
auto *group = new InnerDecklistNode("Creature", board);
|
||||
cardNode(group, "Swords", 1);
|
||||
cardNode(group, "Bolt", 3);
|
||||
|
||||
root.setSortMethod(DeckSortMethod::ByName);
|
||||
|
||||
auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, group, Qt::AscendingOrder);
|
||||
EXPECT_EQ(childNames(group), (QStringList{"Bolt", "Swords"}));
|
||||
ASSERT_EQ(mapping.size(), 2);
|
||||
EXPECT_EQ(mapping[0].first, 1); // "Bolt" was originally at row 1
|
||||
EXPECT_EQ(mapping[0].second, 0);
|
||||
EXPECT_EQ(mapping[1].first, 0);
|
||||
EXPECT_EQ(mapping[1].second, 1);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
283
tests/deck_list_model/deck_list_model_zone_integration_test.cpp
Normal file
283
tests/deck_list_model/deck_list_model_zone_integration_test.cpp
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/card/card_info.h>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/card/game_specific_terms.h>
|
||||
#include <libcockatrice/card/printing/exact_card.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/deck_list/deck_list_node_tree.h>
|
||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
int totalCustomZoneRows(const DeckListModel &model)
|
||||
{
|
||||
int count = 0;
|
||||
const int rootRows = model.rowCount(QModelIndex());
|
||||
for (int r = 0; r < rootRows; ++r) {
|
||||
const QModelIndex board = model.index(r, 0, QModelIndex());
|
||||
const int childRows = model.rowCount(board);
|
||||
for (int c = 0; c < childRows; ++c) {
|
||||
const QModelIndex child = model.index(c, 0, board);
|
||||
if (child.data(DeckRoles::IsCustomZoneRole).toBool()) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
QModelIndex findBoardIndex(const DeckListModel &model, const QString &boardName)
|
||||
{
|
||||
for (int r = 0; r < model.rowCount(QModelIndex()); ++r) {
|
||||
const QModelIndex idx = model.index(r, 0, QModelIndex());
|
||||
if (idx.data(DeckRoles::IsCardRole).toBool()) {
|
||||
continue;
|
||||
}
|
||||
const QString name = idx.sibling(idx.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
if (name == boardName) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QModelIndex findZoneRow(const DeckListModel &model, const QModelIndex &board)
|
||||
{
|
||||
for (int r = 0; r < model.rowCount(board); ++r) {
|
||||
const QModelIndex child = model.index(r, 0, board);
|
||||
if (child.data(DeckRoles::IsCustomZoneRole).toBool()) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// The "Add to Zone" combobox/submenu lists getCustomZoneNames(), which reads the
|
||||
// deck tree. These verify the source data a freshly-created zone populates.
|
||||
|
||||
TEST(DeckListModelZoneIntegration, CreateZoneThenReadCustomZoneNames)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal"}));
|
||||
}
|
||||
|
||||
TEST(DeckListModelZoneIntegration, CreateTwoZonesThenReadBoth)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr);
|
||||
EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"}));
|
||||
}
|
||||
|
||||
// Mirroring regression: rebuildTree must mirror each custom zone exactly once.
|
||||
TEST(DeckListModelZoneIntegration, RebuildTreeMirrorsEachZoneOnce)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
// One direct mainboard card plus two nested custom zones.
|
||||
tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1);
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
tree->addCard("Swords to Plowshares", 1, "Removal", -1);
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr);
|
||||
|
||||
model.rebuildTree();
|
||||
|
||||
EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"}));
|
||||
EXPECT_EQ(totalCustomZoneRows(model), 2);
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
// Model behaviour: addCard routing, findCard lookup, removeRows guard, empty-zone survival.
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckListModelZoneIntegration, AddCardRoutesIntoMirroredCustomZone)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
model.rebuildTree();
|
||||
|
||||
QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal");
|
||||
ASSERT_TRUE(added.isValid());
|
||||
|
||||
// The card is a direct child of the mirrored custom zone, not a new top-level zone.
|
||||
const QModelIndex zoneParent = added.parent();
|
||||
ASSERT_TRUE(zoneParent.isValid());
|
||||
EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool());
|
||||
EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(),
|
||||
QString("Removal"));
|
||||
|
||||
// No "Removal" top-level zone appeared in the deck tree.
|
||||
auto *listRoot = tree->getRoot();
|
||||
bool topLevelRemoval = false;
|
||||
for (int i = 0; i < listRoot->size(); ++i) {
|
||||
if (auto *zone = dynamic_cast<InnerDecklistNode *>(listRoot->at(i))) {
|
||||
topLevelRemoval |= zone->getName() == "Removal";
|
||||
}
|
||||
}
|
||||
EXPECT_FALSE(topLevelRemoval);
|
||||
}
|
||||
|
||||
TEST(DeckListModelZoneIntegration, AddCardToUnmirroredCustomZoneRebuildsNotCreatesTopLevel)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
// The zone exists on the deck tree but the shadow tree has never mirrored it.
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
|
||||
QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal");
|
||||
ASSERT_TRUE(added.isValid());
|
||||
|
||||
const QModelIndex zoneParent = added.parent();
|
||||
ASSERT_TRUE(zoneParent.isValid());
|
||||
EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool());
|
||||
EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(),
|
||||
QString("Removal"));
|
||||
}
|
||||
|
||||
TEST(DeckListModelZoneIntegration, AddCardCreatesGroupSeparatelyFromSameNamedZone)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
// A custom zone named exactly like a grouping criterion.
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Creature"), nullptr);
|
||||
model.rebuildTree();
|
||||
|
||||
CardInfoPtr bear = CardInfo::newInstance("Grizzly Bears");
|
||||
bear->setProperty(Mtg::MainCardType, "Creature");
|
||||
|
||||
QModelIndex added = model.addCard(ExactCard(bear), DECK_ZONE_MAIN);
|
||||
ASSERT_TRUE(added.isValid());
|
||||
|
||||
// The card lands in a *group* node called "Creature", not swallowed by the custom zone.
|
||||
const QModelIndex groupParent = added.parent();
|
||||
ASSERT_TRUE(groupParent.isValid());
|
||||
EXPECT_FALSE(groupParent.data(DeckRoles::IsCustomZoneRole).toBool());
|
||||
EXPECT_EQ(groupParent.sibling(groupParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(),
|
||||
QString("Creature"));
|
||||
|
||||
// The board keeps both rows: the "Creature" group and the "Creature" custom zone.
|
||||
const QModelIndex boardIndex = groupParent.parent();
|
||||
ASSERT_TRUE(boardIndex.isValid());
|
||||
EXPECT_EQ(model.rowCount(boardIndex), 2);
|
||||
}
|
||||
|
||||
TEST(DeckListModelZoneIntegration, FindCardResolvesCardInsideCustomZone)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
model.rebuildTree();
|
||||
|
||||
// findCard resolves through the card database; register the card we add.
|
||||
const QString cardName = "Swords to Plowshares";
|
||||
CardInfoPtr info = CardInfo::newInstance(cardName);
|
||||
CardDatabaseManager::getInstance()->addCard(info);
|
||||
|
||||
QModelIndex added = model.addCard(ExactCard(info), "Removal");
|
||||
ASSERT_TRUE(added.isValid());
|
||||
|
||||
QModelIndex found = model.findCard(cardName, "Removal");
|
||||
EXPECT_TRUE(found.isValid());
|
||||
EXPECT_EQ(found, added);
|
||||
}
|
||||
|
||||
TEST(DeckListModelZoneIntegration, RemoveRowsRefusesCustomZoneRow)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1);
|
||||
model.rebuildTree();
|
||||
|
||||
const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN);
|
||||
ASSERT_TRUE(mainIndex.isValid());
|
||||
const QModelIndex zoneRow = findZoneRow(model, mainIndex);
|
||||
ASSERT_TRUE(zoneRow.isValid());
|
||||
|
||||
EXPECT_FALSE(model.removeRow(zoneRow.row(), zoneRow.parent()));
|
||||
EXPECT_EQ(model.rowCount(mainIndex), 2); // the zone survives, alongside the card group
|
||||
}
|
||||
|
||||
TEST(DeckListModelZoneIntegration, EmptyCustomZoneSurvivesMirrorAndPruning)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
// An empty custom zone must be mirrored (the stack deliberately keeps it alive).
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
model.rebuildTree();
|
||||
|
||||
const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN);
|
||||
ASSERT_TRUE(mainIndex.isValid());
|
||||
EXPECT_EQ(model.rowCount(mainIndex), 1);
|
||||
EXPECT_TRUE(findZoneRow(model, mainIndex).isValid());
|
||||
}
|
||||
|
||||
// Regression: a board card named like the requested zone must not be mistaken for
|
||||
// a zone. Previously `findChild` matched any child by name, so a mainboard card
|
||||
// called "Lightning Bolt" made addCard believe a "Lightning Bolt" zone existed and
|
||||
// recurse through rebuildTree forever.
|
||||
TEST(DeckListModelZoneIntegration, AddCardToCardNamedZoneDoesNotRecurse)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1);
|
||||
|
||||
QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Lightning Bolt");
|
||||
ASSERT_TRUE(added.isValid());
|
||||
}
|
||||
|
||||
// Regression: adding to a custom zone that holds a nested sub-zone mirrored the
|
||||
// nested cards as flattened shadow rows, so the sorted shadow row index pointed
|
||||
// past the deck zone's direct children. The card must be appended to the deck
|
||||
// zone instead of being written out of range.
|
||||
TEST(DeckListModelZoneIntegration, AddCardToCustomZoneWithNestedSubZoneAppends)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
auto *removal = tree->addCustomZone(DECK_ZONE_MAIN, "Removal");
|
||||
ASSERT_NE(removal, nullptr);
|
||||
auto *deeper = new InnerDecklistNode("Deeper", removal);
|
||||
new DecklistCardNode("Lightning Bolt", 2, deeper, -1);
|
||||
model.rebuildTree();
|
||||
|
||||
QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Removal");
|
||||
ASSERT_TRUE(added.isValid());
|
||||
ASSERT_TRUE(added.parent().data(DeckRoles::IsCustomZoneRole).toBool());
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
|
@ -213,6 +213,42 @@ TEST(DeckListZones, MoveCustomZoneMovesCards)
|
|||
EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board"));
|
||||
}
|
||||
|
||||
TEST(DeckListZones, MoveCustomZoneFailsForUnknownBoard)
|
||||
{
|
||||
DeckList deck;
|
||||
auto *tree = deck.getTree();
|
||||
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
tree->addCard("Lightning Bolt", 2, "Removal", -1);
|
||||
|
||||
EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board"));
|
||||
|
||||
// The zone is still under main.
|
||||
EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1);
|
||||
}
|
||||
|
||||
// Regression: findCustomZoneByName walks every top-level zone, so a custom zone
|
||||
// an imported deck carries under a non-standard board (tokens) is still found and
|
||||
// movable. The pre-fix manager-level moveCustomZone only scanned the standard
|
||||
// boards and returned false for these with no feedback.
|
||||
TEST(DeckListZones, MoveCustomZoneNestedUnderTokensBoard)
|
||||
{
|
||||
DeckList deck;
|
||||
auto *tree = deck.getTree();
|
||||
auto *root = tree->getRoot();
|
||||
|
||||
auto *tokens = new InnerDecklistNode(DECK_ZONE_TOKENS, root);
|
||||
auto *removal = new InnerDecklistNode("Removal", tokens);
|
||||
new DecklistCardNode("Lightning Bolt", 2, removal, -1);
|
||||
|
||||
EXPECT_TRUE(tree->findCustomZoneByName("Removal"));
|
||||
EXPECT_TRUE(tree->moveCustomZone("Removal", DECK_ZONE_SIDE));
|
||||
|
||||
auto pairs = collectBoardCardPairs(deck);
|
||||
EXPECT_FALSE(hasPair(pairs, DECK_ZONE_TOKENS, "Lightning Bolt"));
|
||||
EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt"));
|
||||
}
|
||||
|
||||
TEST(DeckListZones, RemoveCustomZoneRemovesCards)
|
||||
{
|
||||
DeckList deck;
|
||||
|
|
|
|||
83
tests/metrics_registry_test.cpp
Normal file
83
tests/metrics_registry_test.cpp
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#include <QCoreApplication>
|
||||
#include <QList>
|
||||
#include <gtest/gtest.h>
|
||||
#include <metrics_registry.h>
|
||||
|
||||
TEST(MetricsRegistryTest, EmptyRegistryHasZeroedCounters)
|
||||
{
|
||||
MetricsRegistry registry;
|
||||
|
||||
EXPECT_EQ(0, registry.totalCommands());
|
||||
EXPECT_EQ(0, registry.totalTimeMs());
|
||||
EXPECT_EQ(0, registry.activeTypeCount());
|
||||
EXPECT_EQ(0, registry.getGameStartSnapshot().count);
|
||||
}
|
||||
|
||||
TEST(MetricsRegistryTest, SampleIsRecordedInTotalsAndSlot)
|
||||
{
|
||||
MetricsRegistry registry;
|
||||
registry.observeCommand(MetricsRegistry::typeIdFor(0, 1000), 7);
|
||||
|
||||
EXPECT_EQ(1, registry.totalCommands());
|
||||
EXPECT_EQ(7, registry.totalTimeMs());
|
||||
EXPECT_EQ(1, registry.activeTypeCount());
|
||||
|
||||
const auto stats = registry.collectActiveStats();
|
||||
ASSERT_EQ(1, stats.size());
|
||||
EXPECT_EQ(MetricsRegistry::typeIdFor(0, 1000), stats[0].typeId);
|
||||
EXPECT_EQ(1, stats[0].count);
|
||||
EXPECT_EQ(7, stats[0].totalMs);
|
||||
}
|
||||
|
||||
TEST(MetricsRegistryTest, KindEncodingSeparatesSameExtensionNumber)
|
||||
{
|
||||
MetricsRegistry registry;
|
||||
const int sessionPing = MetricsRegistry::typeIdFor(0, 1000);
|
||||
const int roomLeaveRoom = MetricsRegistry::typeIdFor(1, 1000);
|
||||
ASSERT_NE(sessionPing, roomLeaveRoom);
|
||||
|
||||
registry.observeCommand(sessionPing, 1);
|
||||
registry.observeCommand(roomLeaveRoom, 5000);
|
||||
|
||||
EXPECT_EQ(2, registry.activeTypeCount());
|
||||
}
|
||||
|
||||
TEST(MetricsRegistryTest, OutOfRangeIdsLandInOverflowSlot)
|
||||
{
|
||||
MetricsRegistry registry;
|
||||
registry.observeCommand(-1, 4);
|
||||
registry.observeCommand(MetricsRegistry::MaxTypes + 12345, 4);
|
||||
|
||||
EXPECT_EQ(2, registry.totalCommands());
|
||||
EXPECT_EQ(1, registry.activeTypeCount()); // both collapsed into one slot
|
||||
EXPECT_EQ(8, registry.totalTimeMs());
|
||||
}
|
||||
|
||||
TEST(MetricsRegistryTest, NegativeDurationsAreClamped)
|
||||
{
|
||||
MetricsRegistry registry;
|
||||
registry.observeCommand(MetricsRegistry::typeIdFor(0, 1000), -50);
|
||||
|
||||
EXPECT_EQ(0, registry.totalTimeMs());
|
||||
}
|
||||
|
||||
TEST(MetricsRegistryTest, GameStartTrackedSeparatelyFromCommands)
|
||||
{
|
||||
MetricsRegistry registry;
|
||||
registry.observeGameStartDurationMs(120);
|
||||
|
||||
EXPECT_EQ(0, registry.totalCommands());
|
||||
EXPECT_EQ(0, registry.totalTimeMs());
|
||||
EXPECT_EQ(0, registry.activeTypeCount());
|
||||
|
||||
const auto snapshot = registry.getGameStartSnapshot();
|
||||
EXPECT_EQ(1, snapshot.count);
|
||||
EXPECT_EQ(120, snapshot.totalMs);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
|
@ -7,3 +7,63 @@ endif()
|
|||
target_link_libraries(parse_cipt_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES})
|
||||
|
||||
add_test(NAME parse_cipt_test COMMAND parse_cipt_test)
|
||||
|
||||
# Oracle importer unit tests
|
||||
add_executable(
|
||||
oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp
|
||||
../../oracle/src/raw_json_scanner.cpp oracle_importer_test.cpp
|
||||
)
|
||||
|
||||
if(NOT GTEST_FOUND)
|
||||
add_dependencies(oracle_importer_test gtest)
|
||||
endif()
|
||||
|
||||
target_link_libraries(
|
||||
oracle_importer_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES}
|
||||
${TEST_QT_MODULES}
|
||||
)
|
||||
|
||||
add_test(NAME oracle_importer_test COMMAND oracle_importer_test)
|
||||
|
||||
# Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark)
|
||||
# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark
|
||||
# can download and decompress whatever AllPrintings format the default URL selects.
|
||||
find_package(ZLIB)
|
||||
if(ZLIB_FOUND)
|
||||
add_definitions("-DHAS_ZLIB")
|
||||
set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp)
|
||||
set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES})
|
||||
include_directories(${ZLIB_INCLUDE_DIRS})
|
||||
else()
|
||||
message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled")
|
||||
endif()
|
||||
|
||||
find_package(LibLZMA)
|
||||
if(LIBLZMA_FOUND)
|
||||
add_definitions("-DHAS_LZMA")
|
||||
list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp)
|
||||
list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES})
|
||||
include_directories(${LIBLZMA_INCLUDE_DIRS})
|
||||
else()
|
||||
message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled")
|
||||
endif()
|
||||
|
||||
add_executable(
|
||||
oracle_importer_benchmark_test
|
||||
${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp
|
||||
../../oracle/src/raw_json_scanner.cpp oracle_importer_benchmark_test.cpp ${_ORACLE_BENCH_EXTRA_SOURCES}
|
||||
)
|
||||
|
||||
if(NOT GTEST_FOUND)
|
||||
add_dependencies(oracle_importer_benchmark_test gtest)
|
||||
endif()
|
||||
|
||||
target_link_libraries(
|
||||
oracle_importer_benchmark_test
|
||||
libcockatrice_card
|
||||
libcockatrice_interfaces
|
||||
Threads::Threads
|
||||
${GTEST_BOTH_LIBRARIES}
|
||||
${TEST_QT_MODULES}
|
||||
${_ORACLE_BENCH_EXTRA_LIBRARIES}
|
||||
)
|
||||
|
|
|
|||
578
tests/oracle/oracle_importer_benchmark_test.cpp
Normal file
578
tests/oracle/oracle_importer_benchmark_test.cpp
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
#include "../../oracle/src/oracleimporter.h"
|
||||
|
||||
#include "gtest/gtest.h"
|
||||
#include <QBuffer>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QElapsedTimer>
|
||||
#include <QEventLoop>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
|
||||
|
||||
#if defined(HAS_LZMA)
|
||||
#include "../../oracle/src/lzma/decompress.h"
|
||||
#endif
|
||||
#if defined(HAS_ZLIB)
|
||||
#include "../../oracle/src/zip/unzip.h"
|
||||
#endif
|
||||
#if defined(Q_OS_MACOS)
|
||||
#include <mach/mach.h>
|
||||
#include <sys/resource.h>
|
||||
#endif
|
||||
|
||||
// Helper: build a synthetic MTGJSON-style JSON with the given number of sets and cards per set
|
||||
static QByteArray buildSyntheticData(int numSets, int cardsPerSet)
|
||||
{
|
||||
QJsonObject dataObj;
|
||||
for (int s = 0; s < numSets; ++s) {
|
||||
QJsonArray cardsArray;
|
||||
for (int c = 0; c < cardsPerSet; ++c) {
|
||||
QJsonObject card;
|
||||
card["name"] = QString("Card %1").arg(s * cardsPerSet + c);
|
||||
card["text"] = "This is a test card with some rules text.";
|
||||
card["layout"] = "normal";
|
||||
card["manaCost"] = "{W}";
|
||||
card["type"] = "Creature — Human";
|
||||
card["power"] = "2";
|
||||
card["toughness"] = "2";
|
||||
card["colors"] = QJsonArray{"W"};
|
||||
card["colorIdentity"] = QJsonArray{"W"};
|
||||
card["types"] = QJsonArray{"Creature"};
|
||||
// Real MTGJSON types: floats and booleans, not strings. This
|
||||
// exercises the QVariant coercion in the property reader.
|
||||
card["convertedManaCost"] = 1.0;
|
||||
card["manaValue"] = 1.0;
|
||||
card["isOnlineOnly"] = false;
|
||||
card["isRebalanced"] = false;
|
||||
|
||||
QJsonObject legalities;
|
||||
legalities["standard"] = "legal";
|
||||
legalities["modern"] = "legal";
|
||||
legalities["legacy"] = "legal";
|
||||
legalities["vintage"] = "legal";
|
||||
legalities["commander"] = "legal";
|
||||
card["legalities"] = legalities;
|
||||
|
||||
QJsonObject identifiers;
|
||||
identifiers["scryfallId"] = QString("id-%1-%2").arg(s).arg(c);
|
||||
card["identifiers"] = identifiers;
|
||||
|
||||
// In AllPrintings, number and rarity are flat fields on the card
|
||||
// object, exactly as set below.
|
||||
card["number"] = QString::number(c + 1);
|
||||
card["rarity"] = "common";
|
||||
|
||||
cardsArray.append(card);
|
||||
}
|
||||
|
||||
QJsonObject setObj;
|
||||
setObj["code"] = QString("T%1").arg(s, 2, 10, QChar('0'));
|
||||
setObj["name"] = QString("Test Set %1").arg(s);
|
||||
setObj["type"] = "expansion";
|
||||
setObj["releaseDate"] = "2024-01-01";
|
||||
setObj["cards"] = cardsArray;
|
||||
|
||||
dataObj[QString("T%1").arg(s, 2, 10, QChar('0'))] = setObj;
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root["data"] = dataObj;
|
||||
return QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Import throughput benchmark
|
||||
// ============================================================================
|
||||
|
||||
TEST(OracleBenchmark, ImportThroughput)
|
||||
{
|
||||
static constexpr int numSets = 10;
|
||||
static constexpr int cardsPerSet = 500;
|
||||
|
||||
QByteArray data = buildSyntheticData(numSets, cardsPerSet);
|
||||
|
||||
OracleImporter importer;
|
||||
|
||||
// Phase 1: Parse JSON
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
bool ok = importer.readSetsFromByteArray(data);
|
||||
ASSERT_TRUE(ok);
|
||||
qint64 parseMs = timer.elapsed();
|
||||
|
||||
// Phase 2: Import cards
|
||||
timer.restart();
|
||||
int importedSets = importer.startImport();
|
||||
qint64 importMs = timer.elapsed();
|
||||
|
||||
int totalImported = 0;
|
||||
for (const auto &card : importer.getCardList()) {
|
||||
Q_UNUSED(card);
|
||||
totalImported++;
|
||||
}
|
||||
|
||||
// The fixture generates globally unique card names, so the expected
|
||||
// counts are exact: a regression here means cards were dropped.
|
||||
ASSERT_EQ(importedSets, numSets);
|
||||
ASSERT_EQ(totalImported, numSets * cardsPerSet);
|
||||
// Real-data probe: numeric convertedManaCost must be coerced to text
|
||||
// (regression for the QJsonValue::toString() reader in #7214).
|
||||
auto probeCard = importer.getCardList().value("Card 0");
|
||||
ASSERT_FALSE(probeCard.isNull());
|
||||
ASSERT_EQ(probeCard->getProperty("cmc"), "1");
|
||||
|
||||
qDebug().noquote()
|
||||
<< QString("Oracle Import Benchmark: %1 sets, %2 unique cards").arg(importedSets).arg(totalImported);
|
||||
qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs);
|
||||
qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs);
|
||||
qDebug().noquote() << QString(" Total: %1 ms").arg(parseMs + importMs);
|
||||
if (importMs > 0) {
|
||||
qDebug().noquote() << QString(" Throughput: %1 cards/sec")
|
||||
.arg(static_cast<double>(totalImported) / importMs * 1000.0, 0, 'f', 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// readSetsFromByteArray benchmark
|
||||
// ============================================================================
|
||||
|
||||
TEST(OracleBenchmark, ParseJsonThroughput)
|
||||
{
|
||||
static constexpr int numSets = 20;
|
||||
static constexpr int cardsPerSet = 1000;
|
||||
|
||||
QByteArray data = buildSyntheticData(numSets, cardsPerSet);
|
||||
|
||||
// Run 5 iterations and report average
|
||||
static constexpr int iterations = 5;
|
||||
qint64 totalMs = 0;
|
||||
|
||||
for (int i = 0; i < iterations; ++i) {
|
||||
OracleImporter importer;
|
||||
QByteArray source = data;
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
bool ok = importer.readSetsFromByteArray(std::move(source));
|
||||
ASSERT_TRUE(ok);
|
||||
totalMs += timer.elapsed();
|
||||
}
|
||||
|
||||
qint64 avgMs = totalMs / iterations;
|
||||
qDebug().noquote() << QString("Parse Benchmark (%1 iterations): avg %2 ms for %3 sets x %4 cards")
|
||||
.arg(iterations)
|
||||
.arg(avgMs)
|
||||
.arg(numSets)
|
||||
.arg(cardsPerSet);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Split card merging benchmark
|
||||
// ============================================================================
|
||||
|
||||
TEST(OracleBenchmark, SplitCardMerging)
|
||||
{
|
||||
static constexpr int numSplitCards = 1000;
|
||||
|
||||
QJsonArray cardsList;
|
||||
for (int i = 0; i < numSplitCards; ++i) {
|
||||
QJsonObject face1;
|
||||
face1["name"] = QString("Fire %1 // Ice %1").arg(i);
|
||||
face1["text"] = "Fire side text.";
|
||||
face1["layout"] = "split";
|
||||
face1["side"] = "a";
|
||||
face1["faceName"] = QString("Fire %1").arg(i);
|
||||
face1["colors"] = QJsonArray{"R"};
|
||||
face1["colorIdentity"] = QJsonArray{"R"};
|
||||
face1["types"] = QJsonArray{"Instant"};
|
||||
face1["manaCost"] = "{R}";
|
||||
face1["legalities"] = QJsonObject{{"standard", "not_legal"}};
|
||||
face1["identifiers"] = QJsonObject{{"scryfallId", QString("f-%1").arg(i)}};
|
||||
face1["number"] = QString::number(i + 1);
|
||||
face1["rarity"] = "uncommon";
|
||||
|
||||
QJsonObject face2;
|
||||
face2["name"] = QString("Fire %1 // Ice %1").arg(i);
|
||||
face2["text"] = "Ice side text.";
|
||||
face2["layout"] = "split";
|
||||
face2["side"] = "b";
|
||||
face2["faceName"] = QString("Ice %1").arg(i);
|
||||
face2["colors"] = QJsonArray{"U"};
|
||||
face2["colorIdentity"] = QJsonArray{"U"};
|
||||
face2["types"] = QJsonArray{"Instant"};
|
||||
face2["manaCost"] = "{U}";
|
||||
face2["legalities"] = QJsonObject{{"standard", "not_legal"}};
|
||||
face2["identifiers"] = QJsonObject{{"scryfallId", QString("i-%1").arg(i)}};
|
||||
face2["number"] = QString::number(i + 1);
|
||||
face2["rarity"] = "uncommon";
|
||||
|
||||
cardsList.append(face1);
|
||||
cardsList.append(face2);
|
||||
}
|
||||
|
||||
NoopCardSetPriorityController controller;
|
||||
OracleImporter importer;
|
||||
CardSetPtr set = CardSet::newInstance(&controller, "TST", "Split Test");
|
||||
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
int count = importer.importCardsFromSet(set, cardsList);
|
||||
qint64 ms = timer.elapsed();
|
||||
|
||||
ASSERT_EQ(count, numSplitCards);
|
||||
qDebug().noquote() << QString("Split Card Merge Benchmark: %1 cards in %2 ms (%3 cards/sec)")
|
||||
.arg(count)
|
||||
.arg(ms)
|
||||
.arg(ms > 0 ? static_cast<double>(count) / ms * 1000.0 : 0.0, 0, 'f', 0);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// sortAndReduceColors microbenchmark
|
||||
// ============================================================================
|
||||
|
||||
// We can't call sortAndReduceColors directly (it's static), so we benchmark
|
||||
// through importCardsFromSet with color properties.
|
||||
|
||||
TEST(OracleBenchmark, ImportCardsWithColors)
|
||||
{
|
||||
static constexpr int numCards = 10000;
|
||||
|
||||
NoopCardSetPriorityController controller;
|
||||
OracleImporter importer;
|
||||
CardSetPtr set = CardSet::newInstance(&controller, "TST", "Color Test");
|
||||
|
||||
QJsonArray cardsList;
|
||||
for (int i = 0; i < numCards; ++i) {
|
||||
QJsonObject card;
|
||||
card["name"] = QString("Color Card %1").arg(i);
|
||||
card["text"] = "Rules text.";
|
||||
card["layout"] = "normal";
|
||||
card["manaCost"] = "{W}";
|
||||
card["type"] = "Creature — Human";
|
||||
card["types"] = QJsonArray{"Creature"};
|
||||
card["colors"] = QJsonArray{"B", "R", "G", "W", "U"};
|
||||
card["colorIdentity"] = QJsonArray{"B", "R", "G", "W", "U"};
|
||||
card["number"] = QString::number(i + 1);
|
||||
card["rarity"] = "common";
|
||||
card["legalities"] = QJsonObject{{"standard", "legal"}};
|
||||
card["identifiers"] = QJsonObject{{"scryfallId", QString("c-%1").arg(i)}};
|
||||
cardsList.append(card);
|
||||
}
|
||||
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
int count = importer.importCardsFromSet(set, cardsList);
|
||||
qint64 ms = timer.elapsed();
|
||||
|
||||
ASSERT_EQ(count, numCards);
|
||||
qDebug().noquote() << QString("Import with Colors Benchmark: %1 cards in %2 ms (%3 cards/sec)")
|
||||
.arg(count)
|
||||
.arg(ms)
|
||||
.arg(ms > 0 ? static_cast<double>(count) / ms * 1000.0 : 0.0, 0, 'f', 0);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RAM usage measurement
|
||||
// ============================================================================
|
||||
|
||||
// Mirrors the default AllPrintings URL selection in oracle/src/pages.cpp.
|
||||
#if defined(HAS_LZMA)
|
||||
static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.xz");
|
||||
#elif defined(HAS_ZLIB)
|
||||
static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.zip");
|
||||
#else
|
||||
static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json");
|
||||
#endif
|
||||
|
||||
// Magic bytes also from oracle/src/pages.cpp
|
||||
static const QByteArray kXzSignature("\xFD\x37\x7A\x58\x5A", 6);
|
||||
static const QByteArray kZipSignature("PK");
|
||||
|
||||
struct MemorySnapshot
|
||||
{
|
||||
qint64 peakRssKb = -1; // process high-water mark (VmHWM on Linux, ru_maxrss on macOS)
|
||||
qint64 rssKb = -1; // current resident set size
|
||||
bool available = false;
|
||||
|
||||
static MemorySnapshot current()
|
||||
{
|
||||
MemorySnapshot snap;
|
||||
#if defined(Q_OS_LINUX)
|
||||
QFile statusFile("/proc/self/status");
|
||||
if (statusFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
// /proc files report size() == 0, so atEnd() is immediately true: read everything first.
|
||||
const QList<QByteArray> lines = statusFile.readAll().split('\n');
|
||||
for (const QByteArray &line : lines) {
|
||||
if (line.startsWith("VmHWM:")) {
|
||||
snap.peakRssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong();
|
||||
} else if (line.startsWith("VmRSS:")) {
|
||||
snap.rssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong();
|
||||
}
|
||||
}
|
||||
snap.available = snap.peakRssKb >= 0;
|
||||
}
|
||||
#elif defined(Q_OS_MACOS)
|
||||
struct rusage usage;
|
||||
if (getrusage(RUSAGE_SELF, &usage) == 0) {
|
||||
snap.peakRssKb = usage.ru_maxrss / 1024; // bytes -> kB
|
||||
snap.available = snap.peakRssKb >= 0;
|
||||
}
|
||||
// getrusage has no current-RSS equivalent; task_info's resident_size
|
||||
// is the closest macOS analog to Linux VmRSS.
|
||||
mach_task_basic_info info = {};
|
||||
mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
|
||||
if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast<task_info_t>(&info), &count) ==
|
||||
KERN_SUCCESS) {
|
||||
snap.rssKb = info.resident_size / 1024;
|
||||
}
|
||||
#endif
|
||||
return snap;
|
||||
}
|
||||
};
|
||||
|
||||
static QString formatKb(qint64 kb)
|
||||
{
|
||||
if (kb < 0) {
|
||||
return "N/A";
|
||||
}
|
||||
return QString("%1 MB").arg(kb / 1024.0, 0, 'f', 1);
|
||||
}
|
||||
|
||||
static void logRamPhase(const QString &phase, const MemorySnapshot &baseline, const MemorySnapshot ¤t)
|
||||
{
|
||||
if (!baseline.available || !current.available) {
|
||||
qDebug().noquote() << QString(" %1: memory stats unavailable on this platform").arg(phase);
|
||||
return;
|
||||
}
|
||||
// VmHWM / ru_maxrss are monotonically non-decreasing high-water marks, so a
|
||||
// peak-based delta between phases is ~0.0 MB by construction once the
|
||||
// fixture build has set the process peak. The live signals are current RSS
|
||||
// and the process peak; the delta is meaningful only where the baseline was
|
||||
// taken immediately before the phase it measures (e.g. the import phase,
|
||||
// which compares afterParse against afterImport).
|
||||
QString rssDelta = "N/A";
|
||||
if (current.rssKb >= 0 && baseline.rssKb >= 0) {
|
||||
rssDelta = formatKb(current.rssKb - baseline.rssKb);
|
||||
}
|
||||
qDebug().noquote() << QString(" %1: current RSS %2 | delta vs baseline %3 | process peak %4")
|
||||
.arg(phase)
|
||||
.arg(formatKb(current.rssKb))
|
||||
.arg(rssDelta)
|
||||
.arg(formatKb(current.peakRssKb));
|
||||
}
|
||||
|
||||
// Decompresses the download payload when the default URL is a compressed build,
|
||||
// mirroring the wizard's magic-byte handling in oracle/src/pages.cpp.
|
||||
static QByteArray decompressSetsData(const QByteArray &payload)
|
||||
{
|
||||
if (payload.startsWith(kXzSignature)) {
|
||||
#if defined(HAS_LZMA)
|
||||
QBuffer inBuffer(const_cast<QByteArray *>(&payload));
|
||||
QByteArray out;
|
||||
QBuffer outBuffer(&out);
|
||||
inBuffer.open(QIODevice::ReadOnly);
|
||||
outBuffer.open(QIODevice::WriteOnly);
|
||||
XzDecompressor xz;
|
||||
if (!xz.decompress(&inBuffer, &outBuffer)) {
|
||||
qDebug() << "RAM benchmark: xz decompression failed";
|
||||
return {};
|
||||
}
|
||||
return out;
|
||||
#else
|
||||
qDebug() << "RAM benchmark: download is xz-compressed but this build has no LZMA support";
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
if (payload.startsWith(kZipSignature)) {
|
||||
#if defined(HAS_ZLIB)
|
||||
QBuffer inBuffer(const_cast<QByteArray *>(&payload));
|
||||
inBuffer.open(QIODevice::ReadOnly);
|
||||
UnZip unzip;
|
||||
if (unzip.openArchive(&inBuffer) != UnZip::Ok) {
|
||||
qDebug() << "RAM benchmark: zip archive open failed";
|
||||
return {};
|
||||
}
|
||||
if (unzip.fileList().size() != 1) {
|
||||
qDebug() << "RAM benchmark: zip archive doesn't contain exactly one file";
|
||||
return {};
|
||||
}
|
||||
QByteArray out;
|
||||
QBuffer outBuffer(&out);
|
||||
outBuffer.open(QIODevice::WriteOnly);
|
||||
const auto errorCode = unzip.extractFile(unzip.fileList().value(0), &outBuffer);
|
||||
unzip.closeArchive();
|
||||
if (errorCode != UnZip::Ok) {
|
||||
qDebug() << "RAM benchmark: zip extraction failed";
|
||||
return {};
|
||||
}
|
||||
return out;
|
||||
#else
|
||||
qDebug() << "RAM benchmark: download is zip-compressed but this build has no zlib support";
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
TEST(OracleBenchmark, ImportRamUsage)
|
||||
{
|
||||
static constexpr int numSets = 30;
|
||||
static constexpr int cardsPerSet = 2000; // ~60k cards, roughly AllPrintings scale
|
||||
|
||||
// Baseline must precede the fixture build: a high-water mark set while
|
||||
// generating the synthetic JSON would otherwise mask the importer phases.
|
||||
// Where memory stats are unavailable (Windows), skip before doing the
|
||||
// 60k-card fixture build, which would otherwise be pure wasted work.
|
||||
const MemorySnapshot baseline = MemorySnapshot::current();
|
||||
if (!baseline.available) {
|
||||
GTEST_SKIP() << "Memory stats unavailable on this platform";
|
||||
}
|
||||
|
||||
const QByteArray data = buildSyntheticData(numSets, cardsPerSet);
|
||||
|
||||
// The fixture build leaves freed-but-unreturned arenas behind (current RSS
|
||||
// rarely falls once glibc allocates). Baseline immediately after it so the
|
||||
// parse phase measures only the importer's own growth (~40 MB) rather than
|
||||
// swallowing the fixture builder's spike.
|
||||
const MemorySnapshot afterFixture = MemorySnapshot::current();
|
||||
logRamPhase("fixture build", baseline, afterFixture);
|
||||
|
||||
NoopCardSetPriorityController controller;
|
||||
OracleImporter importer;
|
||||
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
ASSERT_TRUE(importer.readSetsFromByteArray(std::move(data)));
|
||||
const qint64 parseMs = timer.elapsed();
|
||||
const MemorySnapshot afterParse = MemorySnapshot::current();
|
||||
|
||||
timer.restart();
|
||||
const int importedSets = importer.startImport();
|
||||
const qint64 importMs = timer.elapsed();
|
||||
const MemorySnapshot afterImport = MemorySnapshot::current();
|
||||
|
||||
importer.releaseSetData();
|
||||
const MemorySnapshot afterRelease = MemorySnapshot::current();
|
||||
|
||||
const int totalCards = importer.getCardList().size();
|
||||
qDebug().noquote() << QString("Oracle RAM Benchmark (synthetic): %1 sets, %2 cards, %3 MB JSON")
|
||||
.arg(importedSets)
|
||||
.arg(totalCards)
|
||||
.arg(data.size() / (1024.0 * 1024.0), 0, 'f', 1);
|
||||
qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs);
|
||||
qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs);
|
||||
logRamPhase("parse", afterFixture, afterParse);
|
||||
logRamPhase("import", afterParse, afterImport);
|
||||
logRamPhase("after releaseSetData()", afterImport, afterRelease);
|
||||
|
||||
// Freeing the parsed tree rarely moves current RSS (allocator reuse), so the
|
||||
// meaningful signal that release actually dropped the buffers is emptiness,
|
||||
// not an RSS delta.
|
||||
ASSERT_TRUE(importer.getSets().isEmpty());
|
||||
}
|
||||
|
||||
TEST(OracleBenchmark, ImportRamUsageAllPrintings)
|
||||
{
|
||||
// Only "1" enables the download: unset (the default and the CI setup) and
|
||||
// an explicit "0" both disable it.
|
||||
bool envOk = false;
|
||||
const int enabled = qEnvironmentVariableIntValue("COCKATRICE_ORACLE_RAM_BENCHMARK", &envOk);
|
||||
if (!envOk || enabled == 0) {
|
||||
GTEST_SKIP() << "Set COCKATRICE_ORACLE_RAM_BENCHMARK=1 to download the real AllPrintings dataset for this "
|
||||
"RAM benchmark. Default URL: "
|
||||
<< kDefaultAllPrintingsUrl.toDisplayString().toStdString();
|
||||
}
|
||||
|
||||
// Baseline must precede the request so the phase covers the download +
|
||||
// decompress step, including the payload materialized by readAll().
|
||||
const MemorySnapshot baseline = MemorySnapshot::current();
|
||||
if (!baseline.available) {
|
||||
GTEST_SKIP() << "Memory stats unavailable on this platform";
|
||||
}
|
||||
|
||||
QNetworkAccessManager nam;
|
||||
QNetworkRequest request(kDefaultAllPrintingsUrl);
|
||||
request.setHeader(QNetworkRequest::UserAgentHeader, "Cockatrice Oracle RAM benchmark");
|
||||
QNetworkReply *reply = nam.get(request);
|
||||
|
||||
QEventLoop loop;
|
||||
QTimer timeoutTimer;
|
||||
timeoutTimer.setSingleShot(true);
|
||||
bool timedOut = false;
|
||||
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, [&] {
|
||||
timedOut = true;
|
||||
reply->abort();
|
||||
});
|
||||
timeoutTimer.start(10 * 60 * 1000);
|
||||
loop.exec();
|
||||
timeoutTimer.stop();
|
||||
|
||||
// abort() leaves reply->error() as OperationCanceledError, so a timed-out
|
||||
// download takes the same GTEST_SKIP path as any other network error
|
||||
// instead of reading a truncated body and failing the parse below.
|
||||
if (timedOut || reply->error() != QNetworkReply::NoError) {
|
||||
GTEST_SKIP() << "Download failed: " << reply->errorString().toStdString();
|
||||
}
|
||||
const QByteArray payload = reply->readAll();
|
||||
reply->deleteLater();
|
||||
|
||||
// mtgjson can answer 200 with an HTML page (mirrors the wizard's '<' check
|
||||
// in pages.cpp); reject it before trying to decompress/parse.
|
||||
if (payload.startsWith("<")) {
|
||||
GTEST_SKIP() << "Download returned a non-JSON body (HTML page instead of data), skipping";
|
||||
}
|
||||
|
||||
const QByteArray setsData = decompressSetsData(payload);
|
||||
const MemorySnapshot afterDownload = MemorySnapshot::current();
|
||||
if (setsData.isEmpty()) {
|
||||
GTEST_SKIP() << "No data to import (download or decompression failed)";
|
||||
}
|
||||
|
||||
NoopCardSetPriorityController controller;
|
||||
OracleImporter importer;
|
||||
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
ASSERT_TRUE(importer.readSetsFromByteArray(std::move(setsData)));
|
||||
const qint64 parseMs = timer.elapsed();
|
||||
const MemorySnapshot afterParse = MemorySnapshot::current();
|
||||
|
||||
timer.restart();
|
||||
const int importedSets = importer.startImport();
|
||||
const qint64 importMs = timer.elapsed();
|
||||
const MemorySnapshot afterImport = MemorySnapshot::current();
|
||||
|
||||
importer.releaseSetData();
|
||||
const MemorySnapshot afterRelease = MemorySnapshot::current();
|
||||
|
||||
const int totalCards = importer.getCardList().size();
|
||||
qDebug().noquote() << QString("Oracle RAM Benchmark (real AllPrintings): %1 sets, %2 unique cards")
|
||||
.arg(importedSets)
|
||||
.arg(totalCards);
|
||||
qDebug().noquote() << QString(" URL: %1").arg(kDefaultAllPrintingsUrl.toDisplayString());
|
||||
qDebug().noquote() << QString(" Downloaded: %1 MB, decompressed: %2 MB")
|
||||
.arg(payload.size() / (1024.0 * 1024.0), 0, 'f', 1)
|
||||
.arg(setsData.size() / (1024.0 * 1024.0), 0, 'f', 1);
|
||||
qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs);
|
||||
qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs);
|
||||
logRamPhase("download+decompress", baseline, afterDownload);
|
||||
logRamPhase("parse", afterDownload, afterParse);
|
||||
logRamPhase("import", afterParse, afterImport);
|
||||
logRamPhase("after releaseSetData()", afterImport, afterRelease);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
// Required for the event loop used by the real-AllPrintings download benchmark
|
||||
QCoreApplication app(argc, argv);
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
879
tests/oracle/oracle_importer_test.cpp
Normal file
879
tests/oracle/oracle_importer_test.cpp
Normal file
|
|
@ -0,0 +1,879 @@
|
|||
#include "../../oracle/src/oracleimporter.h"
|
||||
|
||||
#include "gtest/gtest.h"
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QPair>
|
||||
#include <QSet>
|
||||
#include <libcockatrice/card/format/format_legality_rules.h>
|
||||
#include <libcockatrice/card/set/card_set.h>
|
||||
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
|
||||
|
||||
class OracleImporterTest : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
void SetUp() override
|
||||
{
|
||||
controller = new NoopCardSetPriorityController();
|
||||
importer = new OracleImporter();
|
||||
set = CardSet::newInstance(controller, "TST", "Test Set");
|
||||
}
|
||||
|
||||
void TearDown() override
|
||||
{
|
||||
delete importer;
|
||||
delete controller;
|
||||
}
|
||||
|
||||
// Helper: build a minimal card JSON object
|
||||
QJsonObject makeCard(const QString &name,
|
||||
const QString &colors = "",
|
||||
const QString &colorIdentity = "",
|
||||
const QVariantMap &legalities = {})
|
||||
{
|
||||
QJsonObject card;
|
||||
card["name"] = name;
|
||||
card["text"] = "Rules text.";
|
||||
card["layout"] = "normal";
|
||||
card["manaCost"] = "{W}";
|
||||
card["type"] = "Creature — Human";
|
||||
card["types"] = QJsonArray{"Creature"};
|
||||
card["number"] = "1";
|
||||
card["rarity"] = "common";
|
||||
|
||||
if (!colors.isEmpty()) {
|
||||
QJsonArray arr;
|
||||
for (const QChar &c : colors) {
|
||||
arr.append(QString(c));
|
||||
}
|
||||
card["colors"] = arr;
|
||||
}
|
||||
if (!colorIdentity.isEmpty()) {
|
||||
QJsonArray arr;
|
||||
for (const QChar &c : colorIdentity) {
|
||||
arr.append(QString(c));
|
||||
}
|
||||
card["colorIdentity"] = arr;
|
||||
}
|
||||
if (!legalities.isEmpty()) {
|
||||
QJsonObject legalObj;
|
||||
for (auto it = legalities.constBegin(); it != legalities.constEnd(); ++it) {
|
||||
legalObj[it.key()] = it.value().toString();
|
||||
}
|
||||
card["legalities"] = legalObj;
|
||||
}
|
||||
|
||||
QJsonObject identifiers;
|
||||
identifiers["scryfallId"] = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
card["identifiers"] = identifiers;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
NoopCardSetPriorityController *controller;
|
||||
OracleImporter *importer;
|
||||
CardSetPtr set;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// sortAndReduceColors tests (tested via importCardsFromSet)
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(OracleImporterTest, SortAndReduceColorsSingleColor)
|
||||
{
|
||||
QJsonArray cards{makeCard("Red Card", "R", "R")};
|
||||
importer->importCardsFromSet(set, cards);
|
||||
|
||||
auto card = importer->getCardList().value("Red Card");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
ASSERT_EQ(card->getProperty("colors"), "R");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, SortAndReduceColorsDeduplicates)
|
||||
{
|
||||
QJsonArray cards{makeCard("Dedup Card", "WWUUB", "WU")};
|
||||
importer->importCardsFromSet(set, cards);
|
||||
|
||||
auto card = importer->getCardList().value("Dedup Card");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
ASSERT_EQ(card->getProperty("colors"), "WUB");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, SortAndReduceColorsSortsWUBRG)
|
||||
{
|
||||
QJsonArray cards{makeCard("Sort Card", "RGW", "RGW")};
|
||||
importer->importCardsFromSet(set, cards);
|
||||
|
||||
auto card = importer->getCardList().value("Sort Card");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
ASSERT_EQ(card->getProperty("colors"), "WRG");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, SortAndReduceColorsAllFive)
|
||||
{
|
||||
QJsonArray cards{makeCard("Five Color", "BRGWU", "BRGWU")};
|
||||
importer->importCardsFromSet(set, cards);
|
||||
|
||||
auto card = importer->getCardList().value("Five Color");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
ASSERT_EQ(card->getProperty("colors"), "WUBRG");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, SortAndReduceColorIdentity)
|
||||
{
|
||||
QJsonArray cards{makeCard("Color Id Card", "W", "GWR")};
|
||||
importer->importCardsFromSet(set, cards);
|
||||
|
||||
auto card = importer->getCardList().value("Color Id Card");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
ASSERT_EQ(card->getProperty("coloridentity"), "WRG");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, SingleColorNotSorted)
|
||||
{
|
||||
QJsonArray cards{makeCard("Single Card", "B", "B")};
|
||||
importer->importCardsFromSet(set, cards);
|
||||
|
||||
auto card = importer->getCardList().value("Single Card");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
ASSERT_EQ(card->getProperty("colors"), "B");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Legality guard tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(OracleImporterTest, NewCardKeepsLegalityProperties)
|
||||
{
|
||||
// Verifies that format-* properties survive addCard on a fresh card
|
||||
// (not the combineLegalities guard, which only runs on existing printings).
|
||||
QVariantMap leg;
|
||||
leg["standard"] = "legal";
|
||||
leg["modern"] = "legal";
|
||||
QJsonArray cards{makeCard("Legal Card", "", "", leg)};
|
||||
|
||||
importer->importCardsFromSet(set, cards);
|
||||
auto card = importer->getCardList().value("Legal Card");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
ASSERT_EQ(card->getProperty("format-standard"), "legal");
|
||||
ASSERT_EQ(card->getProperty("format-modern"), "legal");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, LegalityMergeAllowedWhenCardHasNoLegalities)
|
||||
{
|
||||
// First printing carries no legalities at all, so the guard's
|
||||
// `properties.filter(formatRegex).empty()` predicate is true and the
|
||||
// second printing's legalities must be merged in.
|
||||
QJsonArray cards1{makeCard("Unmerged Card")};
|
||||
importer->importCardsFromSet(set, cards1);
|
||||
|
||||
CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set");
|
||||
QVariantMap leg;
|
||||
leg["standard"] = "legal";
|
||||
QJsonArray cards2{makeCard("Unmerged Card", "", "", leg)};
|
||||
importer->importCardsFromSet(set2, cards2);
|
||||
|
||||
auto card = importer->getCardList().value("Unmerged Card");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
ASSERT_EQ(card->getProperty("format-standard"), "legal");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, LegalityGuardPreservesFirstPrinting)
|
||||
{
|
||||
// First printing: standard=legal, modern=legal
|
||||
QVariantMap leg1;
|
||||
leg1["standard"] = "legal";
|
||||
leg1["modern"] = "legal";
|
||||
QJsonArray cards1{makeCard("Guarded Card", "", "", leg1)};
|
||||
importer->importCardsFromSet(set, cards1);
|
||||
|
||||
// Second printing: standard=banned, modern=not_legal
|
||||
CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set");
|
||||
QVariantMap leg2;
|
||||
leg2["standard"] = "banned";
|
||||
leg2["modern"] = "not_legal";
|
||||
QJsonArray cards2{makeCard("Guarded Card", "", "", leg2)};
|
||||
importer->importCardsFromSet(set2, cards2);
|
||||
|
||||
auto card = importer->getCardList().value("Guarded Card");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
// Guard should preserve first printing's legalities
|
||||
ASSERT_EQ(card->getProperty("format-standard"), "legal");
|
||||
ASSERT_EQ(card->getProperty("format-modern"), "legal");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// createDefaultMagicFormats tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(OracleImporterTest, CreateDefaultMagicFormatsContainsExpectedFormats)
|
||||
{
|
||||
auto formats = importer->createDefaultMagicFormats();
|
||||
ASSERT_TRUE(formats.contains("standard"));
|
||||
ASSERT_TRUE(formats.contains("modern"));
|
||||
ASSERT_TRUE(formats.contains("legacy"));
|
||||
ASSERT_TRUE(formats.contains("vintage"));
|
||||
ASSERT_TRUE(formats.contains("commander"));
|
||||
ASSERT_TRUE(formats.contains("pauper"));
|
||||
ASSERT_TRUE(formats.contains("pioneer"));
|
||||
ASSERT_TRUE(formats.contains("brawl"));
|
||||
ASSERT_TRUE(formats.contains("historic"));
|
||||
ASSERT_TRUE(formats.contains("timeless"));
|
||||
ASSERT_TRUE(formats.contains("duel"));
|
||||
ASSERT_TRUE(formats.contains("oathbreaker"));
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, CreateDefaultMagicFormatsSingletonDeckSizes)
|
||||
{
|
||||
auto formats = importer->createDefaultMagicFormats();
|
||||
auto commander = formats.value("commander");
|
||||
ASSERT_FALSE(commander.isNull());
|
||||
ASSERT_EQ(commander->minDeckSize, 100);
|
||||
ASSERT_EQ(commander->maxDeckSize, 100);
|
||||
ASSERT_EQ(commander->maxSideboardSize, 15);
|
||||
|
||||
auto brawl = formats.value("brawl");
|
||||
ASSERT_FALSE(brawl.isNull());
|
||||
ASSERT_EQ(brawl->minDeckSize, 60);
|
||||
ASSERT_EQ(brawl->maxDeckSize, 60);
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, CreateDefaultMagicFormatsVintageHasRestricted)
|
||||
{
|
||||
auto formats = importer->createDefaultMagicFormats();
|
||||
auto vintage = formats.value("vintage");
|
||||
ASSERT_FALSE(vintage.isNull());
|
||||
bool hasRestricted = false;
|
||||
for (const auto &ac : vintage->allowedCounts) {
|
||||
if (ac.label == "restricted") {
|
||||
hasRestricted = true;
|
||||
ASSERT_EQ(ac.max, 1);
|
||||
}
|
||||
}
|
||||
ASSERT_TRUE(hasRestricted);
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, CreateDefaultMagicFormatsRegexMatchesBasicLands)
|
||||
{
|
||||
auto formats = importer->createDefaultMagicFormats();
|
||||
auto standard = formats.value("standard");
|
||||
ASSERT_FALSE(standard.isNull());
|
||||
ASSERT_FALSE(standard->exceptions.isEmpty());
|
||||
|
||||
auto &basicLandsException = standard->exceptions.first();
|
||||
ASSERT_FALSE(basicLandsException.conditions.isEmpty());
|
||||
|
||||
auto &condition = basicLandsException.conditions.first();
|
||||
ASSERT_EQ(condition.field, "type");
|
||||
ASSERT_EQ(condition.matchType, "regex");
|
||||
|
||||
// Verify the regex actually works (was broken before: \b = backspace, not word boundary)
|
||||
QRegularExpression regex(condition.value);
|
||||
ASSERT_TRUE(regex.isValid());
|
||||
ASSERT_TRUE(regex.match("Basic Land — Forest").hasMatch());
|
||||
ASSERT_TRUE(regex.match("Basic Snow Land — Mountain").hasMatch());
|
||||
ASSERT_FALSE(regex.match("Creature — Elf Warrior").hasMatch());
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, CreateDefaultMagicFormatsCaching)
|
||||
{
|
||||
// The memoized map returns the same FormatRulesPtr instances, so the
|
||||
// shared pointers must be identical across calls. This is the only
|
||||
// observable effect of the cache: contents would match either way.
|
||||
auto first = importer->createDefaultMagicFormats();
|
||||
auto second = importer->createDefaultMagicFormats();
|
||||
ASSERT_EQ(first.value("standard").data(), second.value("standard").data());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// readSetsFromByteArray tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(OracleImporterTest, ReadSetsFromByteArrayValidJson)
|
||||
{
|
||||
QJsonObject setObj;
|
||||
setObj["code"] = "tst";
|
||||
setObj["name"] = "Test Set";
|
||||
setObj["type"] = "expansion";
|
||||
setObj["releaseDate"] = "2024-01-01";
|
||||
setObj["cards"] = QJsonArray();
|
||||
|
||||
QJsonObject root;
|
||||
root["data"] = QJsonObject{{"TST", setObj}};
|
||||
|
||||
QByteArray data = QJsonDocument(root).toJson();
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(data));
|
||||
ASSERT_EQ(importer->getSets().size(), 1);
|
||||
ASSERT_EQ(importer->getSets().first().getShortName(), "TST");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ReadSetsFromByteArrayInvalidJson)
|
||||
{
|
||||
QByteArray data = "not valid json";
|
||||
ASSERT_FALSE(importer->readSetsFromByteArray(data));
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmptyData)
|
||||
{
|
||||
QJsonObject root;
|
||||
root["data"] = QJsonObject();
|
||||
|
||||
QByteArray data = QJsonDocument(root).toJson();
|
||||
ASSERT_FALSE(importer->readSetsFromByteArray(data));
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ReadSetsFromByteArrayCapitalizesSetType)
|
||||
{
|
||||
QJsonObject setObj;
|
||||
setObj["code"] = "ftv";
|
||||
setObj["name"] = "From The Vault";
|
||||
setObj["type"] = "from_the_vault";
|
||||
setObj["releaseDate"] = "2024-01-01";
|
||||
setObj["cards"] = QJsonArray();
|
||||
|
||||
QJsonObject root;
|
||||
root["data"] = QJsonObject{{"FTV", setObj}};
|
||||
|
||||
QByteArray data = QJsonDocument(root).toJson();
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(data));
|
||||
ASSERT_EQ(importer->getSets().first().getSetType(), "From the Vault");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ReadSetsFromByteArraySortsSetsByName)
|
||||
{
|
||||
// QJsonObject iterates keys in lexicographic order ("AAA" before "ZZZ"),
|
||||
// so leaving the natural order matching the alphabetical sort makes the
|
||||
// assertion pass trivially. Inverting it keeps the sort meaningful:
|
||||
// iteration yields "AAA" (Zeta Set) first, then the sort by name must
|
||||
// promote "ZZZ" (Alpha Set) to the front.
|
||||
QJsonObject setA;
|
||||
setA["code"] = "aaa";
|
||||
setA["name"] = "Zeta Set";
|
||||
setA["type"] = "expansion";
|
||||
setA["releaseDate"] = "2024-01-01";
|
||||
setA["cards"] = QJsonArray();
|
||||
|
||||
QJsonObject setB;
|
||||
setB["code"] = "zzz";
|
||||
setB["name"] = "Alpha Set";
|
||||
setB["type"] = "expansion";
|
||||
setB["releaseDate"] = "2024-01-01";
|
||||
setB["cards"] = QJsonArray();
|
||||
|
||||
QJsonObject root;
|
||||
root["data"] = QJsonObject{{"AAA", setA}, {"ZZZ", setB}};
|
||||
|
||||
QByteArray data = QJsonDocument(root).toJson();
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(data));
|
||||
auto sets = importer->getSets();
|
||||
ASSERT_GE(sets.size(), 2);
|
||||
ASSERT_EQ(sets.first().getShortName(), "ZZZ");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Split card coloridentity tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(OracleImporterTest, SplitCardColorIdentityConcatenated)
|
||||
{
|
||||
QJsonObject leg{{"standard", "not_legal"}};
|
||||
|
||||
QJsonObject face1;
|
||||
face1["name"] = "Fire // Ice";
|
||||
face1["text"] = "Fire deals 2 damage.";
|
||||
face1["layout"] = "split";
|
||||
face1["side"] = "a";
|
||||
face1["faceName"] = "Fire";
|
||||
face1["colors"] = QJsonArray{"R"};
|
||||
face1["colorIdentity"] = QJsonArray{"R"};
|
||||
face1["types"] = QJsonArray{"Instant"};
|
||||
face1["manaCost"] = "{R}";
|
||||
face1["legalities"] = leg;
|
||||
face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}};
|
||||
face1["number"] = "1";
|
||||
face1["rarity"] = "uncommon";
|
||||
|
||||
QJsonObject face2;
|
||||
face2["name"] = "Fire // Ice";
|
||||
face2["text"] = "Ice taps target artifact.";
|
||||
face2["layout"] = "split";
|
||||
face2["side"] = "b";
|
||||
face2["faceName"] = "Ice";
|
||||
face2["colors"] = QJsonArray{"U"};
|
||||
face2["colorIdentity"] = QJsonArray{"U"};
|
||||
face2["types"] = QJsonArray{"Instant"};
|
||||
face2["manaCost"] = "{U}";
|
||||
face2["legalities"] = leg;
|
||||
face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}};
|
||||
face2["number"] = "1";
|
||||
face2["rarity"] = "uncommon";
|
||||
|
||||
QJsonArray cardsList{face1, face2};
|
||||
int count = importer->importCardsFromSet(set, cardsList);
|
||||
ASSERT_EQ(count, 1);
|
||||
|
||||
auto card = importer->getCardList().value("Fire // Ice");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
|
||||
// coloridentity should be "RU" (concatenated), then sorted to "UR"
|
||||
// by sortAndReduceColors when it reaches addCard
|
||||
ASSERT_EQ(card->getProperty("coloridentity"), "UR");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, SplitCardColorsConcatenated)
|
||||
{
|
||||
QJsonObject leg{{"standard", "not_legal"}};
|
||||
|
||||
QJsonObject face1;
|
||||
face1["name"] = "Fire // Ice";
|
||||
face1["text"] = "Fire deals 2 damage.";
|
||||
face1["layout"] = "split";
|
||||
face1["side"] = "a";
|
||||
face1["faceName"] = "Fire";
|
||||
face1["colors"] = QJsonArray{"R"};
|
||||
face1["colorIdentity"] = QJsonArray{"R"};
|
||||
face1["types"] = QJsonArray{"Instant"};
|
||||
face1["manaCost"] = "{R}";
|
||||
face1["legalities"] = leg;
|
||||
face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}};
|
||||
face1["number"] = "1";
|
||||
face1["rarity"] = "uncommon";
|
||||
|
||||
QJsonObject face2;
|
||||
face2["name"] = "Fire // Ice";
|
||||
face2["text"] = "Ice taps target artifact.";
|
||||
face2["layout"] = "split";
|
||||
face2["side"] = "b";
|
||||
face2["faceName"] = "Ice";
|
||||
face2["colors"] = QJsonArray{"U"};
|
||||
face2["colorIdentity"] = QJsonArray{"U"};
|
||||
face2["types"] = QJsonArray{"Instant"};
|
||||
face2["manaCost"] = "{U}";
|
||||
face2["legalities"] = leg;
|
||||
face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}};
|
||||
face2["number"] = "1";
|
||||
face2["rarity"] = "uncommon";
|
||||
|
||||
QJsonArray cardsList{face1, face2};
|
||||
importer->importCardsFromSet(set, cardsList);
|
||||
|
||||
auto card = importer->getCardList().value("Fire // Ice");
|
||||
ASSERT_FALSE(card.isNull());
|
||||
|
||||
QString colors = card->getProperty("colors");
|
||||
ASSERT_FALSE(colors.contains("//")) << "colors should not contain '//', got: " << colors.toStdString();
|
||||
ASSERT_TRUE(colors.contains("R"));
|
||||
ASSERT_TRUE(colors.contains("U"));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mana cost formatting tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(OracleImporterTest, ManaCostStripsBraces)
|
||||
{
|
||||
QJsonObject card = makeCard("Mana Card");
|
||||
card["manaCost"] = "{2}{W}{B}";
|
||||
QJsonArray cards{card};
|
||||
|
||||
importer->importCardsFromSet(set, cards);
|
||||
auto result = importer->getCardList().value("Mana Card");
|
||||
ASSERT_FALSE(result.isNull());
|
||||
ASSERT_EQ(result->getProperty("manacost"), "2WB");
|
||||
}
|
||||
|
||||
// cmc comes through as a JSON number ("convertedManaCost"/"manaValue" are
|
||||
// floats in AllPrintings), so this pins the number-to-text coercion that
|
||||
// QJsonValue::toString() dropped in #7214.
|
||||
TEST_F(OracleImporterTest, NumericManaValueCoercedToCmc)
|
||||
{
|
||||
QJsonObject card = makeCard("Cmc Card");
|
||||
card["manaValue"] = 3;
|
||||
QJsonArray cards{card};
|
||||
|
||||
importer->importCardsFromSet(set, cards);
|
||||
auto result = importer->getCardList().value("Cmc Card");
|
||||
ASSERT_FALSE(result.isNull());
|
||||
ASSERT_EQ(result->getProperty("cmc"), "3");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, LegacyConvertedManaCostCoercedToCmc)
|
||||
{
|
||||
QJsonObject card = makeCard("Legacy Cmc Card");
|
||||
card["convertedManaCost"] = 3.0;
|
||||
QJsonArray cards{card};
|
||||
|
||||
importer->importCardsFromSet(set, cards);
|
||||
auto result = importer->getCardList().value("Legacy Cmc Card");
|
||||
ASSERT_FALSE(result.isNull());
|
||||
ASSERT_EQ(result->getProperty("cmc"), "3");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Card deduplication tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(OracleImporterTest, DuplicateCardNameReturnsExisting)
|
||||
{
|
||||
QJsonArray cards{makeCard("Dupe Card")};
|
||||
importer->importCardsFromSet(set, cards);
|
||||
|
||||
CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set");
|
||||
QJsonArray cards2{makeCard("Dupe Card")};
|
||||
importer->importCardsFromSet(set2, cards2);
|
||||
|
||||
ASSERT_EQ(importer->getCardList().size(), 1);
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, AELigatureReplaced)
|
||||
{
|
||||
QJsonObject card = makeCard(QString::fromUtf8("\xC3\x86ther Vial")); // Æther Vial
|
||||
QJsonArray cards{card};
|
||||
|
||||
importer->importCardsFromSet(set, cards);
|
||||
// Æ is replaced with AE, resulting in "AEther Vial"
|
||||
ASSERT_FALSE(importer->getCardList().contains(QString::fromUtf8("\xC3\x86ther Vial")));
|
||||
ASSERT_TRUE(importer->getCardList().contains("AEther Vial"));
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ApostropheNormalized)
|
||||
{
|
||||
QJsonObject card = makeCard(QString::fromUtf8("Jace\u2019s Ingenuity"));
|
||||
QJsonArray cards{card};
|
||||
|
||||
importer->importCardsFromSet(set, cards);
|
||||
ASSERT_TRUE(importer->getCardList().contains("Jace's Ingenuity"));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RawJson scanner tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(OracleImporterTest, ScanSetRangesMatchFullJsonParse)
|
||||
{
|
||||
QJsonObject root;
|
||||
QJsonObject data;
|
||||
data["AAA"] = makeCard("Alpha Card");
|
||||
data["BBB"] = makeCard("Beta Card");
|
||||
root["data"] = data;
|
||||
|
||||
const QByteArray bytes = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
|
||||
RawJson::ScanError error;
|
||||
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(bytes, &error);
|
||||
ASSERT_FALSE(error.isError()) << error.message.toStdString();
|
||||
ASSERT_EQ(ranges.size(), 2);
|
||||
|
||||
const QJsonObject wholeData = QJsonDocument::fromJson(bytes).object().value("data").toObject();
|
||||
for (const RawJson::SetRange &range : ranges) {
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument sliceDoc = QJsonDocument::fromJson(
|
||||
QByteArray(bytes.constData() + range.dataRange.start, range.dataRange.length), &parseError);
|
||||
ASSERT_EQ(parseError.error, QJsonParseError::NoError)
|
||||
<< range.code.toStdString() << ": " << parseError.errorString().toStdString();
|
||||
ASSERT_EQ(sliceDoc.object(), wholeData.value(range.code).toObject()) << "set " << range.code.toStdString();
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ScanSetRangesDecodesEscapesAndCountsCards)
|
||||
{
|
||||
const QByteArray json = "{\"data\":{\"KEY\":{\"code\":\"zzz\",\"name\":\"\\u00c9tude \\ud83d\\ude00\","
|
||||
"\"type\":\"expansion\",\"releaseDate\":\"2024-01-05\","
|
||||
"\"cards\":[{\"name\":\"a\"},{\"name\":\"b\"},{\"name\":\"c\"}]}}}";
|
||||
|
||||
RawJson::ScanError error;
|
||||
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(json, &error);
|
||||
ASSERT_FALSE(error.isError());
|
||||
ASSERT_EQ(ranges.size(), 1);
|
||||
|
||||
const RawJson::SetRange &range = ranges.first();
|
||||
ASSERT_EQ(range.code, "zzz"); // inner "code" wins over the object key
|
||||
const QString expectedName = QString::fromUtf8("\xC3\x89tude ") + QChar(0xD83D) + QChar(0xDE00);
|
||||
ASSERT_EQ(range.name, expectedName);
|
||||
ASSERT_EQ(range.type, "expansion");
|
||||
ASSERT_EQ(range.releaseDate, "2024-01-05");
|
||||
ASSERT_EQ(range.dataRange.cardCount, 3);
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument sliceDoc = QJsonDocument::fromJson(
|
||||
QByteArray(json.constData() + range.dataRange.start, range.dataRange.length), &parseError);
|
||||
ASSERT_EQ(parseError.error, QJsonParseError::NoError);
|
||||
ASSERT_EQ(sliceDoc.object().value("name").toString(), expectedName);
|
||||
ASSERT_EQ(sliceDoc.object().value("cards").toArray().size(), 3);
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ScanSetRangesRejectsInvalidJson)
|
||||
{
|
||||
const QList<QByteArray> invalid = {"not json",
|
||||
"[]",
|
||||
"{\"data\":[]}",
|
||||
"{\"data\":{}}",
|
||||
"{\"other\":{}}",
|
||||
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\","
|
||||
"\"releaseDate\":\"2024-01-01\",\"cards\":[]}}} trailing",
|
||||
"{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\uZZZZ\"}]}}}",
|
||||
"{\"data\":{\"A\":{\"cards\":[{\"name\":\"bad \\q escape\"}]}}}",
|
||||
"{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\ud800\"}]}}}"};
|
||||
|
||||
for (const QByteArray &json : invalid) {
|
||||
RawJson::ScanError error;
|
||||
RawJson::scanSetRanges(json, &error);
|
||||
EXPECT_TRUE(error.isError()) << "expected failure for: " << json.constData();
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ScanSetRangesMatchesFullJsonParseVerdicts)
|
||||
{
|
||||
// Verdicts must agree with QJsonDocument::fromJson for the inputs below —
|
||||
// including the metadata quirks ("name": null, "type": 7, "releaseDate": null,
|
||||
// "cards": null) that used to make the scanner reject sets Qt accepts.
|
||||
const QList<QByteArray> inputs = {
|
||||
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[{"
|
||||
"\"n\":1}]}}}",
|
||||
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":null,\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}",
|
||||
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":null,\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}",
|
||||
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":7,\"releaseDate\":\"2024-01-01\",\"cards\":null}}}",
|
||||
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"releaseDate\":\"2024-01-01\",\"cards\":[1,2,3]}}}",
|
||||
// unescaped control character inside a string: QJsonDocument and
|
||||
// skipString both accept it, so the scanner must not reject the whole doc
|
||||
"{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"N\tX\",\"releaseDate\":\"2024-01-01\",\"cards\":[{\"n\":1}]}}}",
|
||||
// structurally invalid JSON (both parsers must reject)
|
||||
"not json",
|
||||
"{\"data\":{\"A\":{\"name\":\"unterminated}}",
|
||||
};
|
||||
|
||||
for (const QByteArray &input : inputs) {
|
||||
QJsonParseError qtError;
|
||||
QJsonDocument::fromJson(input, &qtError);
|
||||
const bool qtOk = qtError.error == QJsonParseError::NoError;
|
||||
|
||||
RawJson::ScanError scanError;
|
||||
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(input, &scanError);
|
||||
EXPECT_EQ(qtOk, !scanError.isError()) << "verdict mismatch for: " << input.constData();
|
||||
if (scanError.isError()) {
|
||||
continue;
|
||||
}
|
||||
for (const RawJson::SetRange &range : ranges) {
|
||||
QJsonParseError sliceError;
|
||||
QJsonDocument::fromJson(QByteArray(input.constData() + range.dataRange.start, range.dataRange.length),
|
||||
&sliceError);
|
||||
EXPECT_EQ(sliceError.error, QJsonParseError::NoError) << "bad range slice for: " << input.constData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ScanSetRangesRejectsDeepNesting)
|
||||
{
|
||||
// Far beyond the shared 1024 container cap: Qt reports DeepNesting and the
|
||||
// scanner must reject too, without overflowing the stack through its
|
||||
// recursive skipValue walk.
|
||||
QString nesting;
|
||||
nesting.reserve(10000);
|
||||
for (int i = 0; i < 5000; ++i) {
|
||||
nesting += '[';
|
||||
}
|
||||
for (int i = 0; i < 5000; ++i) {
|
||||
nesting += ']';
|
||||
}
|
||||
const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8();
|
||||
|
||||
QJsonParseError qtError;
|
||||
QJsonDocument::fromJson(json, &qtError);
|
||||
ASSERT_NE(qtError.error, QJsonParseError::NoError) << "expected Qt to reject deep nesting";
|
||||
|
||||
RawJson::ScanError scanError;
|
||||
RawJson::scanSetRanges(json, &scanError);
|
||||
ASSERT_TRUE(scanError.isError()) << "scanner accepted a document Qt rejects as too deeply nested";
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ScanSetRangesAcceptsQtMaxNesting)
|
||||
{
|
||||
// Pins the boundary rather than only the far-past case: a depth Qt still
|
||||
// accepts must be accepted by the scanner too. Before the fix the scanner's
|
||||
// cap was roughly half of Qt's (each level cost two decrements), so a
|
||||
// depth of 1000 here was rejected even though QJsonDocument parses it.
|
||||
constexpr int depth = 1000;
|
||||
QString nesting;
|
||||
nesting.reserve(2 * depth);
|
||||
for (int i = 0; i < depth; ++i) {
|
||||
nesting += '[';
|
||||
}
|
||||
for (int i = 0; i < depth; ++i) {
|
||||
nesting += ']';
|
||||
}
|
||||
const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8();
|
||||
|
||||
QJsonParseError qtError;
|
||||
QJsonDocument::fromJson(json, &qtError);
|
||||
ASSERT_EQ(qtError.error, QJsonParseError::NoError) << "expected Qt to accept depth " << depth;
|
||||
|
||||
RawJson::ScanError scanError;
|
||||
RawJson::scanSetRanges(json, &scanError);
|
||||
ASSERT_FALSE(scanError.isError()) << "scanner rejected a document Qt accepts at depth " << depth;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lazy per-set parsing tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(OracleImporterTest, StartImportParsesSetsLazily)
|
||||
{
|
||||
QJsonObject setObj = makeCard("Lazy Import Card");
|
||||
QJsonArray cards;
|
||||
cards.append(setObj);
|
||||
QJsonObject dataSet;
|
||||
dataSet["code"] = "tst";
|
||||
dataSet["name"] = "Test Set";
|
||||
dataSet["type"] = "expansion";
|
||||
dataSet["releaseDate"] = "2024-01-01";
|
||||
dataSet["cards"] = cards;
|
||||
|
||||
QJsonObject root;
|
||||
root["data"] = QJsonObject{{"TST", dataSet}};
|
||||
|
||||
const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(data));
|
||||
ASSERT_FALSE(importer->getRawSetsData().isEmpty());
|
||||
|
||||
const int importedSets = importer->startImport();
|
||||
ASSERT_EQ(importedSets, 1);
|
||||
ASSERT_EQ(importer->getCardList().size(), 1);
|
||||
ASSERT_FALSE(importer->getCardList().value("Lazy Import Card").isNull());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scan progress reporting tests
|
||||
// ============================================================================
|
||||
|
||||
TEST(OracleScanProgress, ScanProgressReportsMonotonicBytesToTotal)
|
||||
{
|
||||
QJsonObject setObj;
|
||||
setObj["code"] = "tst";
|
||||
setObj["name"] = "Test Set";
|
||||
setObj["type"] = "expansion";
|
||||
setObj["releaseDate"] = "2024-01-01";
|
||||
QJsonArray cards;
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
QJsonObject card;
|
||||
card["name"] = QString("Card %1").arg(i);
|
||||
card["text"] = "Some rules text used to bulk up the card payload.";
|
||||
card["layout"] = "normal";
|
||||
cards.append(card);
|
||||
}
|
||||
setObj["cards"] = cards;
|
||||
|
||||
QJsonObject root;
|
||||
root["data"] = QJsonObject{{"TST", setObj}};
|
||||
|
||||
const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
|
||||
QList<QPair<qsizetype, qsizetype>> reports;
|
||||
RawJson::ScanError error;
|
||||
const QList<RawJson::SetRange> ranges =
|
||||
RawJson::scanSetRanges(data, &error, [&reports](qsizetype bytesRead, qsizetype totalBytes) {
|
||||
reports.append({bytesRead, totalBytes});
|
||||
});
|
||||
|
||||
ASSERT_FALSE(error.isError()) << error.message.toStdString();
|
||||
ASSERT_EQ(ranges.size(), 1);
|
||||
ASSERT_FALSE(reports.isEmpty());
|
||||
ASSERT_GT(reports.size(), 1);
|
||||
|
||||
qsizetype last = 0;
|
||||
for (const auto &[bytesRead, totalBytes] : reports) {
|
||||
ASSERT_EQ(totalBytes, data.size());
|
||||
ASSERT_GE(bytesRead, last) << "scan progress must be monotonic";
|
||||
ASSERT_LE(bytesRead, totalBytes) << "scan progress must not overshoot the document size";
|
||||
last = bytesRead;
|
||||
}
|
||||
ASSERT_EQ(reports.constLast().first, data.size()) << "scan must end at 100%";
|
||||
ASSERT_LE(reports.size(), 160) << "scan reports must be throttled";
|
||||
}
|
||||
|
||||
TEST(OracleScanProgress, ScanWithoutCallbackStillParses)
|
||||
{
|
||||
QJsonObject setObj;
|
||||
setObj["code"] = "tst";
|
||||
setObj["name"] = "Test Set";
|
||||
setObj["type"] = "expansion";
|
||||
setObj["releaseDate"] = "2024-01-01";
|
||||
setObj["cards"] = QJsonArray();
|
||||
|
||||
QJsonObject root;
|
||||
root["data"] = QJsonObject{{"TST", setObj}};
|
||||
|
||||
const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
|
||||
RawJson::ScanError error;
|
||||
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(data, &error);
|
||||
|
||||
ASSERT_FALSE(error.isError()) << error.message.toStdString();
|
||||
ASSERT_EQ(ranges.size(), 1);
|
||||
ASSERT_EQ(ranges.first().code, "tst");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmitsScanProgress)
|
||||
{
|
||||
QJsonObject setObj;
|
||||
setObj["code"] = "tst";
|
||||
setObj["name"] = "Test Set";
|
||||
setObj["type"] = "expansion";
|
||||
setObj["releaseDate"] = "2024-01-01";
|
||||
QJsonArray cards;
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
QJsonObject card;
|
||||
card["name"] = QString("Card %1").arg(i);
|
||||
cards.append(card);
|
||||
}
|
||||
setObj["cards"] = cards;
|
||||
|
||||
QJsonObject root;
|
||||
root["data"] = QJsonObject{{"TST", setObj}};
|
||||
|
||||
const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
|
||||
QList<QPair<qsizetype, qsizetype>> emissions;
|
||||
QObject::connect(importer, &OracleImporter::dataReadProgress,
|
||||
[&emissions](int bytesRead, int totalBytes) { emissions.append({bytesRead, totalBytes}); });
|
||||
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(data));
|
||||
ASSERT_FALSE(emissions.isEmpty());
|
||||
for (const auto &[bytesRead, totalBytes] : emissions) {
|
||||
ASSERT_EQ(totalBytes, data.size());
|
||||
ASSERT_GE(bytesRead, 0);
|
||||
ASSERT_LE(bytesRead, totalBytes);
|
||||
}
|
||||
ASSERT_EQ(emissions.constLast().first, data.size());
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, DisablingProgressReportingSuppressesScanEmissions)
|
||||
{
|
||||
QJsonObject setObj;
|
||||
setObj["code"] = "tst";
|
||||
setObj["name"] = "Test Set";
|
||||
setObj["type"] = "expansion";
|
||||
setObj["releaseDate"] = "2024-01-01";
|
||||
setObj["cards"] = QJsonArray();
|
||||
QJsonObject root;
|
||||
root["data"] = QJsonObject{{"TST", setObj}};
|
||||
const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
|
||||
int emissions = 0;
|
||||
QObject::connect(importer, &OracleImporter::dataReadProgress, [&emissions](int, int) { ++emissions; });
|
||||
|
||||
importer->setProgressReporting(false);
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(data));
|
||||
ASSERT_EQ(emissions, 0);
|
||||
|
||||
importer->setProgressReporting(true);
|
||||
ASSERT_TRUE(importer->readSetsFromByteArray(data));
|
||||
ASSERT_GT(emissions, 0);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
|
@ -1,25 +1,9 @@
|
|||
#include "gtest/gtest.h"
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
#include <libcockatrice/rng/rng_sfmt.h>
|
||||
#include <cstring>
|
||||
#include <libcockatrice/utility/passwordhasher.h>
|
||||
|
||||
RNG_Abstract *rng;
|
||||
|
||||
namespace
|
||||
{
|
||||
class PasswordHashTest : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
void SetUp() override
|
||||
{
|
||||
rng = new RNG_SFMT;
|
||||
}
|
||||
|
||||
void TearDown() override
|
||||
{
|
||||
delete rng;
|
||||
}
|
||||
};
|
||||
|
||||
TEST(PasswordHashTest, RegressionTest)
|
||||
{
|
||||
|
|
@ -29,6 +13,29 @@ TEST(PasswordHashTest, RegressionTest)
|
|||
QString hash = PasswordHasher::computeHash(password, salt);
|
||||
ASSERT_EQ(hash, salt + expected) << "The computed hash value remains the same";
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, SaltUsesAlphanumericCharset)
|
||||
{
|
||||
static const char alphanum[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
const QString salt = PasswordHasher::generateRandomSalt();
|
||||
ASSERT_EQ(salt.size(), 16);
|
||||
for (const QChar &c : salt) {
|
||||
ASSERT_NE(strchr(alphanum, c.toLatin1()), nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, SaltsAreUnique)
|
||||
{
|
||||
const QString salt1 = PasswordHasher::generateRandomSalt();
|
||||
const QString salt2 = PasswordHasher::generateRandomSalt();
|
||||
ASSERT_NE(salt1, salt2);
|
||||
}
|
||||
|
||||
TEST(PasswordHashTest, TokenHasExpectedLength)
|
||||
{
|
||||
const QString token = PasswordHasher::generateActivationToken();
|
||||
ASSERT_EQ(token.size(), 16);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv)
|
||||
|
|
|
|||
137
tests/server_developer_role_test.cpp
Normal file
137
tests/server_developer_role_test.cpp
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/** @file server_developer_role_test.cpp
|
||||
* @brief Tests for the developer staff role authorization and dispatch.
|
||||
* @ingroup Tests
|
||||
*/
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/network/server/remote/server.h>
|
||||
#include <libcockatrice/network/server/remote/server_protocolhandler.h>
|
||||
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
|
||||
#include <libcockatrice/protocol/pb/commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/developer_commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
|
||||
// The server_remote library references the global RNG, which is normally
|
||||
// defined by the servatrice/client executable main(). Provide a stub so the
|
||||
// unit test can link against it.
|
||||
RNG_Abstract *rng = nullptr;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
class TestDeveloperHandler : public Server_ProtocolHandler
|
||||
{
|
||||
public:
|
||||
explicit TestDeveloperHandler(Server *_server) : Server_ProtocolHandler(_server, nullptr)
|
||||
{
|
||||
}
|
||||
|
||||
QString getAddress() const override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
QString getConnectionType() const override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
// Buffer the last response code sent to the client so tests can assert on
|
||||
// the outcome of processCommandContainer().
|
||||
Response::ResponseCode lastResponseCode = Response::RespNothing;
|
||||
int dispatchCount = 0;
|
||||
|
||||
protected:
|
||||
void transmitProtocolItem(const ServerMessage &item) override
|
||||
{
|
||||
if (item.message_type() == ServerMessage::RESPONSE) {
|
||||
lastResponseCode = item.response().response_code();
|
||||
}
|
||||
}
|
||||
|
||||
Response::ResponseCode
|
||||
processExtendedDeveloperCommand(int cmdType, const DeveloperCommand &, ResponseContainer &) override
|
||||
{
|
||||
++dispatchCount;
|
||||
// Fail closed for anything not explicitly handled.
|
||||
if (cmdType != DeveloperCommand::GET_SERVER_STATS) {
|
||||
return Response::RespFunctionNotAllowed;
|
||||
}
|
||||
return Response::RespOk;
|
||||
}
|
||||
};
|
||||
|
||||
class DeveloperRoleTest : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
Server server;
|
||||
TestDeveloperHandler handler{&server};
|
||||
|
||||
void setUserLevel(uint32_t level)
|
||||
{
|
||||
ServerInfo_User user;
|
||||
user.set_user_level(level);
|
||||
handler.setUserInfo(user);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(DeveloperRoleTest, RejectsWhenNotLoggedIn)
|
||||
{
|
||||
CommandContainer cont;
|
||||
cont.add_developer_command();
|
||||
handler.processCommandContainer(cont);
|
||||
EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded);
|
||||
EXPECT_EQ(handler.dispatchCount, 0);
|
||||
}
|
||||
|
||||
TEST_F(DeveloperRoleTest, RejectsPlainUser)
|
||||
{
|
||||
setUserLevel(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered);
|
||||
|
||||
CommandContainer cont;
|
||||
cont.add_developer_command();
|
||||
handler.processCommandContainer(cont);
|
||||
EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded);
|
||||
EXPECT_EQ(handler.dispatchCount, 0);
|
||||
}
|
||||
|
||||
TEST_F(DeveloperRoleTest, RejectsModeratorThatIsNotDeveloper)
|
||||
{
|
||||
setUserLevel(ServerInfo_User::IsModerator);
|
||||
|
||||
CommandContainer cont;
|
||||
cont.add_developer_command();
|
||||
handler.processCommandContainer(cont);
|
||||
EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded);
|
||||
}
|
||||
|
||||
TEST_F(DeveloperRoleTest, DispatchesToDeveloperCommandForDeveloper)
|
||||
{
|
||||
setUserLevel(ServerInfo_User::IsDeveloper);
|
||||
|
||||
CommandContainer cont;
|
||||
DeveloperCommand *cmd = cont.add_developer_command();
|
||||
cmd->MutableExtension(Command_GetServerStats::ext);
|
||||
handler.processCommandContainer(cont);
|
||||
EXPECT_EQ(handler.lastResponseCode, Response::RespOk);
|
||||
EXPECT_EQ(handler.dispatchCount, 1);
|
||||
}
|
||||
|
||||
TEST_F(DeveloperRoleTest, FailClosedForUnknownDeveloperCommand)
|
||||
{
|
||||
setUserLevel(ServerInfo_User::IsDeveloper);
|
||||
|
||||
CommandContainer cont;
|
||||
cont.add_developer_command(); // no extension set -> getPbExtension() returns -1
|
||||
handler.processCommandContainer(cont);
|
||||
EXPECT_EQ(handler.lastResponseCode, Response::RespFunctionNotAllowed);
|
||||
EXPECT_EQ(handler.dispatchCount, 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
|
|
@ -238,6 +238,12 @@ TEST_F(SettingsDefaultsTest, Tabs_ModerationOpen_Default)
|
|||
ASSERT_EQ(s.getTabModerationOpen(), false);
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Tabs_CardArtRulesOpen_Default)
|
||||
{
|
||||
TabsSettings s(settingsPath, nullptr);
|
||||
ASSERT_EQ(s.getTabCardArtRulesOpen(), false);
|
||||
}
|
||||
|
||||
// --- ChatSettings ---
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Chat_Mention_Default)
|
||||
|
|
@ -294,6 +300,12 @@ TEST_F(SettingsDefaultsTest, Chat_RoomHistory_Default)
|
|||
ASSERT_EQ(s.getRoomHistory(), true);
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Chat_IgnoreAllPrivateMessages_Default)
|
||||
{
|
||||
ChatSettings s(settingsPath, nullptr);
|
||||
ASSERT_EQ(s.getIgnoreAllPrivateMessages(), false);
|
||||
}
|
||||
|
||||
// --- PersonalSettings ---
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Personal_Lang_Default)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue