mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 09:33:25 -08:00
[Client] DeckSyncProfile + sidecar + DeckDiff with tests
This commit is contained in:
parent
11fdfb4610
commit
be072ffd68
12 changed files with 742 additions and 0 deletions
|
|
@ -26,6 +26,8 @@ set(cockatrice_SOURCES
|
||||||
src/client/settings/shortcuts_settings.cpp
|
src/client/settings/shortcuts_settings.cpp
|
||||||
src/interface/deck_loader/card_node_function.cpp
|
src/interface/deck_loader/card_node_function.cpp
|
||||||
src/interface/deck_loader/deck_loader.cpp
|
src/interface/deck_loader/deck_loader.cpp
|
||||||
|
src/interface/deck_sync/deck_sync_profile.cpp
|
||||||
|
src/interface/deck_sync/deck_sync_sidecar.cpp
|
||||||
src/interface/widgets/dialogs/dlg_connect.cpp
|
src/interface/widgets/dialogs/dlg_connect.cpp
|
||||||
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
|
src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp
|
||||||
src/interface/widgets/dialogs/dlg_create_game.cpp
|
src/interface/widgets/dialogs/dlg_create_game.cpp
|
||||||
|
|
|
||||||
31
cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
Normal file
31
cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#include "deck_sync_profile.h"
|
||||||
|
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
|
||||||
|
DeckSyncProfile::DeckSyncProfile(const QString &_host, int _port, const QString &_username)
|
||||||
|
: host(_host), port(_port), username(_username)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DeckSyncProfile::isValid() const
|
||||||
|
{
|
||||||
|
return !host.isEmpty() && port > 0 && !username.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DeckSyncProfile::key() const
|
||||||
|
{
|
||||||
|
if (!isValid()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// NUL separators keep the triple injective: neither IPv6 hosts nor
|
||||||
|
// usernames containing ':' can collide two profiles into one file.
|
||||||
|
// Lowercasing is safe on both parts: DNS names are case-insensitive, and
|
||||||
|
// Servatrice usernames are unique under a case-insensitive collation
|
||||||
|
// (cockatrice_users uses utf8mb4_unicode_ci), so both spellings are one account.
|
||||||
|
QByteArray blob = host.trimmed().toLower().toUtf8();
|
||||||
|
blob.append('\0');
|
||||||
|
blob.append(QByteArray::number(port));
|
||||||
|
blob.append('\0');
|
||||||
|
blob.append(username.trimmed().toLower().toUtf8());
|
||||||
|
return QCryptographicHash::hash(blob, QCryptographicHash::Sha1).toHex();
|
||||||
|
}
|
||||||
43
cockatrice/src/interface/deck_sync/deck_sync_profile.h
Normal file
43
cockatrice/src/interface/deck_sync/deck_sync_profile.h
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* @file deck_sync_profile.h
|
||||||
|
* @brief Identity of one sync target (server + user) for deck collection sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef DECK_SYNC_PROFILE_H
|
||||||
|
#define DECK_SYNC_PROFILE_H
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class DeckSyncProfile
|
||||||
|
* @brief Identifies which server and login a sync sidecar belongs to.
|
||||||
|
*
|
||||||
|
* A local deck folder can sync against several servers. Every server login
|
||||||
|
* gets its own profile, and every profile gets its own sidecar file, so
|
||||||
|
* switching servers never mixes up remote deck ids.
|
||||||
|
*/
|
||||||
|
class DeckSyncProfile
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
QString host; ///< Server hostname as entered by the user.
|
||||||
|
int port = 0; ///< Server port.
|
||||||
|
QString username; ///< Login name on that server.
|
||||||
|
|
||||||
|
DeckSyncProfile() = default;
|
||||||
|
DeckSyncProfile(const QString &_host, int _port, const QString &_username);
|
||||||
|
|
||||||
|
/** @brief False when any of host, port or username is missing. */
|
||||||
|
[[nodiscard]] bool isValid() const;
|
||||||
|
/**
|
||||||
|
* @brief Stable file-system-safe key for this profile.
|
||||||
|
*
|
||||||
|
* SHA1 hex over the NUL-separated lowercased "host", port and "username"
|
||||||
|
* triple, so the same server reached with different hostname
|
||||||
|
* capitalisation maps to one file, while IPv6 hosts and usernames
|
||||||
|
* containing ':' can never collide two profiles. Empty for invalid
|
||||||
|
* profiles, so no two half-configured profiles share a file.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] QString key() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DECK_SYNC_PROFILE_H
|
||||||
98
cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
Normal file
98
cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
#include "deck_sync_sidecar.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr int SIDECAR_FORMAT_VERSION = 1;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
DeckSyncSidecar::DeckSyncSidecar(const QString &_filePath) : filePath(_filePath)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DeckSyncSidecar::load()
|
||||||
|
{
|
||||||
|
recordMap.clear();
|
||||||
|
|
||||||
|
QFile file(filePath);
|
||||||
|
if (!file.exists()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QJsonDocument document = QJsonDocument::fromJson(file.readAll());
|
||||||
|
if (!document.isObject() || !document.object().value("records").isObject()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QJsonObject records = document.object().value("records").toObject();
|
||||||
|
for (auto it = records.begin(); it != records.end(); ++it) {
|
||||||
|
if (!it.value().isObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QJsonObject entry = it.value().toObject();
|
||||||
|
DeckSyncRecord record;
|
||||||
|
record.guid = entry.value("guid").toString();
|
||||||
|
record.remoteId = entry.value("remoteId").toInt(-1);
|
||||||
|
record.baseHash = entry.value("baseHash").toString();
|
||||||
|
record.lastSyncSecs = static_cast<qint64>(entry.value("lastSync").toDouble());
|
||||||
|
record.deleted = entry.value("deleted").toBool(false);
|
||||||
|
recordMap.insert(it.key(), record);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DeckSyncSidecar::save() const
|
||||||
|
{
|
||||||
|
QJsonObject records;
|
||||||
|
for (auto it = recordMap.begin(); it != recordMap.end(); ++it) {
|
||||||
|
QJsonObject entry;
|
||||||
|
entry.insert("guid", it.value().guid);
|
||||||
|
entry.insert("remoteId", it.value().remoteId);
|
||||||
|
entry.insert("baseHash", it.value().baseHash);
|
||||||
|
entry.insert("lastSync", static_cast<double>(it.value().lastSyncSecs));
|
||||||
|
entry.insert("deleted", it.value().deleted);
|
||||||
|
records.insert(it.key(), entry);
|
||||||
|
}
|
||||||
|
QJsonObject root;
|
||||||
|
root.insert("version", SIDECAR_FORMAT_VERSION);
|
||||||
|
root.insert("records", records);
|
||||||
|
|
||||||
|
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
||||||
|
QFile file(filePath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DeckSyncSidecar::contains(const QString &relativePath) const
|
||||||
|
{
|
||||||
|
return recordMap.contains(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckSyncRecord DeckSyncSidecar::get(const QString &relativePath) const
|
||||||
|
{
|
||||||
|
return recordMap.value(relativePath, DeckSyncRecord());
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeckSyncSidecar::set(const QString &relativePath, const DeckSyncRecord &record)
|
||||||
|
{
|
||||||
|
recordMap.insert(relativePath, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeckSyncSidecar::remove(const QString &relativePath)
|
||||||
|
{
|
||||||
|
recordMap.remove(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
QMap<QString, DeckSyncRecord> DeckSyncSidecar::records() const
|
||||||
|
{
|
||||||
|
return recordMap;
|
||||||
|
}
|
||||||
52
cockatrice/src/interface/deck_sync/deck_sync_sidecar.h
Normal file
52
cockatrice/src/interface/deck_sync/deck_sync_sidecar.h
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* @file deck_sync_sidecar.h
|
||||||
|
* @brief Per-profile local bookkeeping for deck collection sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef DECK_SYNC_SIDECAR_H
|
||||||
|
#define DECK_SYNC_SIDECAR_H
|
||||||
|
|
||||||
|
#include <QMap>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
/** @brief Sync bookkeeping for one local deck file. */
|
||||||
|
struct DeckSyncRecord
|
||||||
|
{
|
||||||
|
QString guid; ///< Stable deck identity shared with the server.
|
||||||
|
int remoteId = -1; ///< Server row id, -1 when never uploaded.
|
||||||
|
QString baseHash; ///< Deck hash at the last successful sync.
|
||||||
|
qint64 lastSyncSecs = 0; ///< Last sync time, seconds since Unix epoch.
|
||||||
|
bool deleted = false; ///< Local tombstone waiting to be pushed.
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class DeckSyncSidecar
|
||||||
|
* @brief Loads and stores sync records as JSON next to the deck folder.
|
||||||
|
*
|
||||||
|
* One sidecar file per DeckSyncProfile (named by profile key), mapping paths
|
||||||
|
* relative to the deck folder to their last-synced state. Comparing the live
|
||||||
|
* file hash against baseHash tells clean apart from locally modified.
|
||||||
|
*/
|
||||||
|
class DeckSyncSidecar
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
QString filePath; ///< Full path of the sidecar JSON file.
|
||||||
|
|
||||||
|
explicit DeckSyncSidecar(const QString &_filePath);
|
||||||
|
|
||||||
|
/** @brief Loads records; a missing file loads as empty and returns true. */
|
||||||
|
bool load();
|
||||||
|
/** @brief Stores records, creating parent folders as needed. */
|
||||||
|
bool save() const;
|
||||||
|
[[nodiscard]] bool contains(const QString &relativePath) const;
|
||||||
|
/** @brief Returns the record, or a default record when unknown. */
|
||||||
|
[[nodiscard]] DeckSyncRecord get(const QString &relativePath) const;
|
||||||
|
void set(const QString &relativePath, const DeckSyncRecord &record);
|
||||||
|
void remove(const QString &relativePath);
|
||||||
|
[[nodiscard]] QMap<QString, DeckSyncRecord> records() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QMap<QString, DeckSyncRecord> recordMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DECK_SYNC_SIDECAR_H
|
||||||
|
|
@ -7,6 +7,7 @@ set(HEADERS
|
||||||
libcockatrice/deck_list/tree/abstract_deck_list_node.h
|
libcockatrice/deck_list/tree/abstract_deck_list_node.h
|
||||||
libcockatrice/deck_list/tree/deck_list_card_node.h
|
libcockatrice/deck_list/tree/deck_list_card_node.h
|
||||||
libcockatrice/deck_list/tree/inner_deck_list_node.h
|
libcockatrice/deck_list/tree/inner_deck_list_node.h
|
||||||
|
libcockatrice/deck_list/deck_diff.h
|
||||||
libcockatrice/deck_list/deck_file_format.h
|
libcockatrice/deck_list/deck_file_format.h
|
||||||
libcockatrice/deck_list/deck_list.h
|
libcockatrice/deck_list/deck_list.h
|
||||||
libcockatrice/deck_list/deck_list_history_manager.h
|
libcockatrice/deck_list/deck_list_history_manager.h
|
||||||
|
|
@ -25,6 +26,7 @@ add_library(
|
||||||
libcockatrice/deck_list/tree/abstract_deck_list_node.cpp
|
libcockatrice/deck_list/tree/abstract_deck_list_node.cpp
|
||||||
libcockatrice/deck_list/tree/deck_list_card_node.cpp
|
libcockatrice/deck_list/tree/deck_list_card_node.cpp
|
||||||
libcockatrice/deck_list/tree/inner_deck_list_node.cpp
|
libcockatrice/deck_list/tree/inner_deck_list_node.cpp
|
||||||
|
libcockatrice/deck_list/deck_diff.cpp
|
||||||
libcockatrice/deck_list/deck_file_format.cpp
|
libcockatrice/deck_list/deck_file_format.cpp
|
||||||
libcockatrice/deck_list/deck_list.cpp
|
libcockatrice/deck_list/deck_list.cpp
|
||||||
libcockatrice/deck_list/deck_list_history_manager.cpp
|
libcockatrice/deck_list/deck_list_history_manager.cpp
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
#include "deck_diff.h"
|
||||||
|
|
||||||
|
#include "deck_list.h"
|
||||||
|
#include "tree/deck_list_card_node.h"
|
||||||
|
#include "tree/inner_deck_list_node.h"
|
||||||
|
|
||||||
|
#include <QMap>
|
||||||
|
#include <QPair>
|
||||||
|
#include <QSet>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
using ZoneCardKey = QPair<QString, QString>;
|
||||||
|
|
||||||
|
QMap<ZoneCardKey, int> zoneCardCounts(const DeckList &deck)
|
||||||
|
{
|
||||||
|
QMap<ZoneCardKey, int> counts;
|
||||||
|
deck.forEachCard([&counts](InnerDecklistNode *zone, DecklistCardNode *card) {
|
||||||
|
counts[{zone->getName(), card->getName()}] += card->getNumber();
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool changeLessThan(const DeckCardChange &a, const DeckCardChange &b)
|
||||||
|
{
|
||||||
|
if (a.zone != b.zone) {
|
||||||
|
return a.zone < b.zone;
|
||||||
|
}
|
||||||
|
return a.cardName < b.cardName;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
DeckDiffResult DeckDiff::diff(const DeckList &oldDeck, const DeckList &newDeck)
|
||||||
|
{
|
||||||
|
const QMap<ZoneCardKey, int> oldCounts = zoneCardCounts(oldDeck);
|
||||||
|
const QMap<ZoneCardKey, int> newCounts = zoneCardCounts(newDeck);
|
||||||
|
|
||||||
|
QSet<ZoneCardKey> keys(oldCounts.keyBegin(), oldCounts.keyEnd());
|
||||||
|
keys.unite(QSet<ZoneCardKey>(newCounts.keyBegin(), newCounts.keyEnd()));
|
||||||
|
|
||||||
|
DeckDiffResult result;
|
||||||
|
for (const ZoneCardKey &key : keys) {
|
||||||
|
const int oldCount = oldCounts.value(key, 0);
|
||||||
|
const int newCount = newCounts.value(key, 0);
|
||||||
|
if (oldCount != newCount) {
|
||||||
|
result.changes.append({key.first, key.second, oldCount, newCount});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort(result.changes.begin(), result.changes.end(), changeLessThan);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DeckDiff::canAutoMerge(const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges)
|
||||||
|
{
|
||||||
|
QSet<ZoneCardKey> localKeys;
|
||||||
|
for (const DeckCardChange &change : localChanges.changes) {
|
||||||
|
localKeys.insert({change.zone, change.cardName});
|
||||||
|
}
|
||||||
|
for (const DeckCardChange &change : remoteChanges.changes) {
|
||||||
|
if (localKeys.contains({change.zone, change.cardName})) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckList
|
||||||
|
DeckDiff::merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges)
|
||||||
|
{
|
||||||
|
QMap<ZoneCardKey, int> counts = zoneCardCounts(baseDeck);
|
||||||
|
for (const DeckCardChange &change : localChanges.changes) {
|
||||||
|
counts[{change.zone, change.cardName}] = change.newCount;
|
||||||
|
}
|
||||||
|
for (const DeckCardChange &change : remoteChanges.changes) {
|
||||||
|
counts[{change.zone, change.cardName}] = change.newCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeckList merged(baseDeck.getMetadata(), DecklistNodeTree(), baseDeck.getSideboardPlans());
|
||||||
|
QList<ZoneCardKey> keys = counts.keys();
|
||||||
|
std::sort(keys.begin(), keys.end());
|
||||||
|
for (const ZoneCardKey &key : keys) {
|
||||||
|
const int count = qMax(0, counts.value(key));
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
merged.addCard(key.second, key.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
71
libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.h
Normal file
71
libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.h
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* @file deck_diff.h
|
||||||
|
* @brief Card-level diff and three-way merge for deck collection sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef DECK_DIFF_H
|
||||||
|
#define DECK_DIFF_H
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class DeckList;
|
||||||
|
|
||||||
|
/** @brief A single card-count change within one zone. */
|
||||||
|
struct DeckCardChange
|
||||||
|
{
|
||||||
|
QString zone; ///< Zone name, e.g. "main".
|
||||||
|
QString cardName; ///< Card name.
|
||||||
|
int oldCount = 0; ///< Copies in the old deck.
|
||||||
|
int newCount = 0; ///< Copies in the new deck.
|
||||||
|
/** @brief Copies added (positive) or removed (negative). */
|
||||||
|
[[nodiscard]] int delta() const
|
||||||
|
{
|
||||||
|
return newCount - oldCount;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @brief Card-level difference between two decks, sorted by zone then card name. */
|
||||||
|
struct DeckDiffResult
|
||||||
|
{
|
||||||
|
QList<DeckCardChange> changes;
|
||||||
|
/** @brief True when both decks hold the same cards in the same zones. */
|
||||||
|
[[nodiscard]] bool isEmpty() const
|
||||||
|
{
|
||||||
|
return changes.isEmpty();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class DeckDiff
|
||||||
|
* @brief Pure card-count diff and three-way merge for deck sync.
|
||||||
|
*
|
||||||
|
* Compares card names and copy counts per zone only; printing information
|
||||||
|
* (set, collector number, provider) is not tracked, so a merged deck keeps
|
||||||
|
* base printings with merged counts.
|
||||||
|
*/
|
||||||
|
class DeckDiff
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
/** @brief Diffs oldDeck against newDeck. */
|
||||||
|
static DeckDiffResult diff(const DeckList &oldDeck, const DeckList &newDeck);
|
||||||
|
/**
|
||||||
|
* @brief True when both change sets touch disjoint (zone, card) keys and a merge needs no arbitration.
|
||||||
|
*/
|
||||||
|
static bool canAutoMerge(const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges);
|
||||||
|
/**
|
||||||
|
* @brief Applies both change sets onto a copy of baseDeck.
|
||||||
|
*
|
||||||
|
* @precondition localChanges and remoteChanges were both computed with
|
||||||
|
* diff() against baseDeck. Stale or foreign change sets silently
|
||||||
|
* resurrect or delete cards.
|
||||||
|
*
|
||||||
|
* Counts are clamped at zero. On overlapping keys the remote change wins;
|
||||||
|
* callers offer this result as "Use Merge" only when canAutoMerge is true.
|
||||||
|
* Metadata and sideboard plans come from baseDeck.
|
||||||
|
*/
|
||||||
|
static DeckList
|
||||||
|
merge(const DeckList &baseDeck, const DeckDiffResult &localChanges, const DeckDiffResult &remoteChanges);
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DECK_DIFF_H
|
||||||
|
|
@ -407,6 +407,34 @@ cockatrice_add_test(
|
||||||
bench
|
bench
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cockatrice_add_test(
|
||||||
|
NAME
|
||||||
|
deck_diff_test
|
||||||
|
SOURCES
|
||||||
|
deck_diff_test.cpp
|
||||||
|
LIBS
|
||||||
|
libcockatrice_deck_list
|
||||||
|
libcockatrice_utility
|
||||||
|
)
|
||||||
|
cockatrice_add_test(
|
||||||
|
NAME
|
||||||
|
deck_sync_profile_test
|
||||||
|
SOURCES
|
||||||
|
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_profile.cpp
|
||||||
|
deck_sync_profile_test.cpp
|
||||||
|
INCLUDE_DIRS
|
||||||
|
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync
|
||||||
|
)
|
||||||
|
cockatrice_add_test(
|
||||||
|
NAME
|
||||||
|
deck_sync_sidecar_test
|
||||||
|
SOURCES
|
||||||
|
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp
|
||||||
|
deck_sync_sidecar_test.cpp
|
||||||
|
INCLUDE_DIRS
|
||||||
|
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_sync
|
||||||
|
)
|
||||||
|
|
||||||
# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist.
|
# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist.
|
||||||
if(TARGET Qt6::lrelease)
|
if(TARGET Qt6::lrelease)
|
||||||
set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm")
|
set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm")
|
||||||
|
|
|
||||||
137
tests/deck_diff_test.cpp
Normal file
137
tests/deck_diff_test.cpp
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
#include "gtest/gtest.h"
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
#include <libcockatrice/deck_list/deck_diff.h>
|
||||||
|
#include <libcockatrice/deck_list/deck_list.h>
|
||||||
|
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||||
|
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
DeckList makeDeck(std::initializer_list<std::pair<const char *, int>> mainCards,
|
||||||
|
std::initializer_list<std::pair<const char *, int>> sideCards = {})
|
||||||
|
{
|
||||||
|
DeckList deck;
|
||||||
|
for (const auto &[name, count] : mainCards) {
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
deck.addCard(name, "main");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto &[name, count] : sideCards) {
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
deck.addCard(name, "side");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deck;
|
||||||
|
}
|
||||||
|
|
||||||
|
int countOf(const DeckList &deck, const char *zone, const char *name)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
deck.forEachCard([&](InnerDecklistNode *zoneNode, DecklistCardNode *card) {
|
||||||
|
if (zoneNode->getName() == zone && card->getName() == name) {
|
||||||
|
count += card->getNumber();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(DeckDiffTest, IdenticalDecksHaveEmptyDiff)
|
||||||
|
{
|
||||||
|
DeckList first = makeDeck({{"Lightning Bolt", 4}});
|
||||||
|
DeckList second = makeDeck({{"Lightning Bolt", 4}});
|
||||||
|
EXPECT_TRUE(DeckDiff::diff(first, second).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckDiffTest, ChangesAreSortedByZoneThenName)
|
||||||
|
{
|
||||||
|
DeckList oldDeck = makeDeck({{"Mountain", 20}, {"Lightning Bolt", 4}}, {{"Shock", 2}});
|
||||||
|
DeckList newDeck = makeDeck({{"Mountain", 19}, {"Shock", 5}}, {{"Shock", 1}, {"Teferi", 1}});
|
||||||
|
|
||||||
|
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
|
||||||
|
ASSERT_EQ(result.changes.size(), 5);
|
||||||
|
EXPECT_EQ(result.changes.at(0).zone, "main");
|
||||||
|
EXPECT_EQ(result.changes.at(0).cardName, "Lightning Bolt");
|
||||||
|
EXPECT_EQ(result.changes.at(1).cardName, "Mountain");
|
||||||
|
EXPECT_EQ(result.changes.at(2).cardName, "Shock");
|
||||||
|
EXPECT_EQ(result.changes.at(3).zone, "side");
|
||||||
|
EXPECT_EQ(result.changes.at(3).cardName, "Shock");
|
||||||
|
EXPECT_EQ(result.changes.at(4).cardName, "Teferi");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckDiffTest, ReportsAddedAndRemovedCopies)
|
||||||
|
{
|
||||||
|
DeckList oldDeck = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 20}});
|
||||||
|
DeckList newDeck = makeDeck({{"Lightning Bolt", 2}, {"Shock", 4}, {"Mountain", 20}});
|
||||||
|
|
||||||
|
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
|
||||||
|
ASSERT_EQ(result.changes.size(), 2);
|
||||||
|
EXPECT_EQ(result.changes.at(0).cardName, "Lightning Bolt");
|
||||||
|
EXPECT_EQ(result.changes.at(0).oldCount, 4);
|
||||||
|
EXPECT_EQ(result.changes.at(0).newCount, 2);
|
||||||
|
EXPECT_EQ(result.changes.at(0).delta(), -2);
|
||||||
|
EXPECT_EQ(result.changes.at(1).cardName, "Shock");
|
||||||
|
EXPECT_EQ(result.changes.at(1).oldCount, 0);
|
||||||
|
EXPECT_EQ(result.changes.at(1).newCount, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckDiffTest, ZoneMoveIsTwoChanges)
|
||||||
|
{
|
||||||
|
DeckList oldDeck = makeDeck({{"Teferi", 1}});
|
||||||
|
DeckList newDeck = makeDeck({}, {{"Teferi", 1}});
|
||||||
|
|
||||||
|
DeckDiffResult result = DeckDiff::diff(oldDeck, newDeck);
|
||||||
|
ASSERT_EQ(result.changes.size(), 2);
|
||||||
|
EXPECT_EQ(result.changes.at(0).zone, "main");
|
||||||
|
EXPECT_EQ(result.changes.at(0).newCount, 0);
|
||||||
|
EXPECT_EQ(result.changes.at(1).zone, "side");
|
||||||
|
EXPECT_EQ(result.changes.at(1).oldCount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckDiffTest, DisjointChangesCanAutoMerge)
|
||||||
|
{
|
||||||
|
DeckList base = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 20}});
|
||||||
|
DeckList local = makeDeck({{"Lightning Bolt", 4}, {"Mountain", 18}});
|
||||||
|
DeckList remote = makeDeck({{"Shock", 2}, {"Lightning Bolt", 4}, {"Mountain", 20}});
|
||||||
|
|
||||||
|
DeckDiffResult localChanges = DeckDiff::diff(base, local);
|
||||||
|
DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
|
||||||
|
EXPECT_TRUE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
|
||||||
|
|
||||||
|
DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
|
||||||
|
EXPECT_EQ(countOf(merged, "main", "Mountain"), 18);
|
||||||
|
EXPECT_EQ(countOf(merged, "main", "Shock"), 2);
|
||||||
|
EXPECT_EQ(countOf(merged, "main", "Lightning Bolt"), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckDiffTest, OverlappingChangesCannotAutoMergeAndRemoteWins)
|
||||||
|
{
|
||||||
|
DeckList base = makeDeck({{"Lightning Bolt", 4}});
|
||||||
|
DeckList local = makeDeck({{"Lightning Bolt", 3}});
|
||||||
|
DeckList remote = makeDeck({{"Lightning Bolt", 1}});
|
||||||
|
|
||||||
|
DeckDiffResult localChanges = DeckDiff::diff(base, local);
|
||||||
|
DeckDiffResult remoteChanges = DeckDiff::diff(base, remote);
|
||||||
|
EXPECT_FALSE(DeckDiff::canAutoMerge(localChanges, remoteChanges));
|
||||||
|
|
||||||
|
DeckList merged = DeckDiff::merge(base, localChanges, remoteChanges);
|
||||||
|
EXPECT_EQ(countOf(merged, "main", "Lightning Bolt"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckDiffTest, MergeKeepsBaseMetadata)
|
||||||
|
{
|
||||||
|
DeckList base = makeDeck({{"Lightning Bolt", 4}});
|
||||||
|
base.setName("Affinity v3");
|
||||||
|
DeckList remote = makeDeck({{"Lightning Bolt", 4}, {"Shock", 1}});
|
||||||
|
|
||||||
|
DeckList merged = DeckDiff::merge(base, DeckDiff::diff(base, base), DeckDiff::diff(base, remote));
|
||||||
|
EXPECT_EQ(merged.getName(), "Affinity v3");
|
||||||
|
EXPECT_EQ(countOf(merged, "main", "Shock"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
::testing::InitGoogleTest(&argc, argv);
|
||||||
|
return RUN_ALL_TESTS();
|
||||||
|
}
|
||||||
63
tests/deck_sync_profile_test.cpp
Normal file
63
tests/deck_sync_profile_test.cpp
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
#include "gtest/gtest.h"
|
||||||
|
#include <QString>
|
||||||
|
#include <deck_sync_profile.h>
|
||||||
|
|
||||||
|
TEST(DeckSyncProfileTest, KeyIsStable)
|
||||||
|
{
|
||||||
|
DeckSyncProfile first("woogerworks.com", 4747, "Skog7");
|
||||||
|
DeckSyncProfile second("woogerworks.com", 4747, "Skog7");
|
||||||
|
EXPECT_EQ(first.key(), second.key());
|
||||||
|
EXPECT_FALSE(first.key().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncProfileTest, KeyIgnoresCase)
|
||||||
|
{
|
||||||
|
DeckSyncProfile lower("woogerworks.com", 4747, "skog7");
|
||||||
|
DeckSyncProfile upper("WOOGERWORKS.COM", 4747, "SKOG7");
|
||||||
|
EXPECT_EQ(lower.key(), upper.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncProfileTest, KeyDiffersPerServerAndUser)
|
||||||
|
{
|
||||||
|
DeckSyncProfile base("woogerworks.com", 4747, "skog7");
|
||||||
|
EXPECT_NE(base.key(), DeckSyncProfile("other.server", 4747, "skog7").key());
|
||||||
|
EXPECT_NE(base.key(), DeckSyncProfile("woogerworks.com", 4748, "skog7").key());
|
||||||
|
EXPECT_NE(base.key(), DeckSyncProfile("woogerworks.com", 4747, "other").key());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncProfileTest, KeyIsUnambiguousAcrossSeparators)
|
||||||
|
{
|
||||||
|
DeckSyncProfile left("a:1", 2, "b");
|
||||||
|
DeckSyncProfile right("a", 1, "2:b");
|
||||||
|
EXPECT_NE(left.key(), right.key());
|
||||||
|
EXPECT_NE(DeckSyncProfile("::1", 4747, "skog7").key(), DeckSyncProfile("::2", 4747, "skog7").key());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncProfileTest, InvalidProfilesHaveNoKey)
|
||||||
|
{
|
||||||
|
EXPECT_TRUE(DeckSyncProfile().key().isEmpty());
|
||||||
|
EXPECT_TRUE(DeckSyncProfile("", 4747, "skog7").key().isEmpty());
|
||||||
|
EXPECT_TRUE(DeckSyncProfile("woogerworks.com", 0, "skog7").key().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncProfileTest, KeyIgnoresSurroundingWhitespace)
|
||||||
|
{
|
||||||
|
DeckSyncProfile plain("woogerworks.com", 4747, "skog7");
|
||||||
|
DeckSyncProfile padded(" woogerworks.com ", 4747, " skog7 ");
|
||||||
|
EXPECT_EQ(plain.key(), padded.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncProfileTest, ValidityRequiresAllFields)
|
||||||
|
{
|
||||||
|
EXPECT_TRUE(DeckSyncProfile("woogerworks.com", 4747, "skog7").isValid());
|
||||||
|
EXPECT_FALSE(DeckSyncProfile("", 4747, "skog7").isValid());
|
||||||
|
EXPECT_FALSE(DeckSyncProfile("woogerworks.com", 0, "skog7").isValid());
|
||||||
|
EXPECT_FALSE(DeckSyncProfile("woogerworks.com", 4747, "").isValid());
|
||||||
|
EXPECT_FALSE(DeckSyncProfile().isValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
::testing::InitGoogleTest(&argc, argv);
|
||||||
|
return RUN_ALL_TESTS();
|
||||||
|
}
|
||||||
126
tests/deck_sync_sidecar_test.cpp
Normal file
126
tests/deck_sync_sidecar_test.cpp
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
#include "gtest/gtest.h"
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QString>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <deck_sync_sidecar.h>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
QString sidecarPath(QTemporaryDir &dir)
|
||||||
|
{
|
||||||
|
return dir.filePath(".sync/profilekey.json");
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(DeckSyncSidecarTest, MissingFileLoadsEmpty)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||||
|
EXPECT_TRUE(sidecar.load());
|
||||||
|
EXPECT_TRUE(sidecar.records().isEmpty());
|
||||||
|
EXPECT_FALSE(sidecar.contains("affinity.cod"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncSidecarTest, RoundTripsRecords)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||||
|
|
||||||
|
DeckSyncRecord record;
|
||||||
|
record.guid = "123e4567-e89b-12d3-a456-426614174000";
|
||||||
|
record.remoteId = 42;
|
||||||
|
record.baseHash = "5cac19qm";
|
||||||
|
record.lastSyncSecs = 1791739200;
|
||||||
|
record.deleted = true;
|
||||||
|
sidecar.set("decks/affinity.cod", record);
|
||||||
|
EXPECT_TRUE(sidecar.save());
|
||||||
|
|
||||||
|
DeckSyncSidecar reloaded(sidecarPath(dir));
|
||||||
|
EXPECT_TRUE(reloaded.load());
|
||||||
|
EXPECT_TRUE(reloaded.contains("decks/affinity.cod"));
|
||||||
|
DeckSyncRecord back = reloaded.get("decks/affinity.cod");
|
||||||
|
EXPECT_EQ(back.guid, record.guid);
|
||||||
|
EXPECT_EQ(back.remoteId, 42);
|
||||||
|
EXPECT_EQ(back.baseHash, "5cac19qm");
|
||||||
|
EXPECT_EQ(back.lastSyncSecs, 1791739200);
|
||||||
|
EXPECT_TRUE(back.deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncSidecarTest, UnknownPathReturnsDefaultRecord)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||||
|
DeckSyncRecord back = sidecar.get("missing.cod");
|
||||||
|
EXPECT_EQ(back.remoteId, -1);
|
||||||
|
EXPECT_TRUE(back.guid.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
bool writeRaw(const QString &path, const QByteArray &bytes)
|
||||||
|
{
|
||||||
|
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||||
|
QFile file(path);
|
||||||
|
return file.open(QIODevice::WriteOnly | QIODevice::Truncate) && file.write(bytes) == bytes.size();
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(DeckSyncSidecarTest, GarbageFileFailsToLoad)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
ASSERT_TRUE(writeRaw(sidecarPath(dir), "not json at all"));
|
||||||
|
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||||
|
EXPECT_FALSE(sidecar.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncSidecarTest, WrongRecordsShapeFailsToLoad)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"records":["a.cod"]})"));
|
||||||
|
DeckSyncSidecar missing(sidecarPath(dir));
|
||||||
|
EXPECT_FALSE(missing.load());
|
||||||
|
|
||||||
|
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"foo":1})"));
|
||||||
|
DeckSyncSidecar other(sidecarPath(dir));
|
||||||
|
EXPECT_FALSE(other.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncSidecarTest, NonObjectEntriesAreSkipped)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
ASSERT_TRUE(writeRaw(sidecarPath(dir), R"({"records":{"a.cod":42,"b.cod":{"guid":"g"}}})"));
|
||||||
|
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||||
|
EXPECT_TRUE(sidecar.load());
|
||||||
|
EXPECT_FALSE(sidecar.contains("a.cod"));
|
||||||
|
EXPECT_TRUE(sidecar.contains("b.cod"));
|
||||||
|
EXPECT_EQ(sidecar.get("b.cod").guid, "g");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DeckSyncSidecarTest, RemoveDropsRecord)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
DeckSyncSidecar sidecar(sidecarPath(dir));
|
||||||
|
sidecar.set("a.cod", DeckSyncRecord());
|
||||||
|
sidecar.remove("a.cod");
|
||||||
|
EXPECT_FALSE(sidecar.contains("a.cod"));
|
||||||
|
EXPECT_TRUE(sidecar.save());
|
||||||
|
|
||||||
|
DeckSyncSidecar reloaded(sidecarPath(dir));
|
||||||
|
EXPECT_TRUE(reloaded.load());
|
||||||
|
EXPECT_FALSE(reloaded.contains("a.cod"));
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
::testing::InitGoogleTest(&argc, argv);
|
||||||
|
return RUN_ALL_TESTS();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue