From 1e97ab086bfee9fbadccf718fb7113c7e5deaa13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 11 Oct 2026 14:26:12 +0200 Subject: [PATCH] [Client] DeckSyncProfile + sidecar + DeckDiff with tests --- cockatrice/CMakeLists.txt | 2 + .../interface/deck_sync/deck_sync_profile.cpp | 31 ++++ .../interface/deck_sync/deck_sync_profile.h | 42 ++++++ .../interface/deck_sync/deck_sync_sidecar.cpp | 97 +++++++++++++ .../interface/deck_sync/deck_sync_sidecar.h | 52 +++++++ libcockatrice_deck_list/CMakeLists.txt | 2 + .../libcockatrice/deck_list/deck_diff.cpp | 89 ++++++++++++ .../libcockatrice/deck_list/deck_diff.h | 71 +++++++++ tests/CMakeLists.txt | 28 ++++ tests/deck_diff_test.cpp | 137 ++++++++++++++++++ tests/deck_sync_profile_test.cpp | 63 ++++++++ tests/deck_sync_sidecar_test.cpp | 126 ++++++++++++++++ 12 files changed, 740 insertions(+) create mode 100644 cockatrice/src/interface/deck_sync/deck_sync_profile.cpp create mode 100644 cockatrice/src/interface/deck_sync/deck_sync_profile.h create mode 100644 cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp create mode 100644 cockatrice/src/interface/deck_sync/deck_sync_sidecar.h create mode 100644 libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.cpp create mode 100644 libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.h create mode 100644 tests/deck_diff_test.cpp create mode 100644 tests/deck_sync_profile_test.cpp create mode 100644 tests/deck_sync_sidecar_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index ce7ad5712..d76d89211 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -26,6 +26,8 @@ set(cockatrice_SOURCES src/client/settings/shortcuts_settings.cpp src/interface/deck_loader/card_node_function.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_convert_deck_to_cod_format.cpp src/interface/widgets/dialogs/dlg_create_game.cpp diff --git a/cockatrice/src/interface/deck_sync/deck_sync_profile.cpp b/cockatrice/src/interface/deck_sync/deck_sync_profile.cpp new file mode 100644 index 000000000..a886128a7 --- /dev/null +++ b/cockatrice/src/interface/deck_sync/deck_sync_profile.cpp @@ -0,0 +1,31 @@ +#include "deck_sync_profile.h" + +#include + +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(); +} diff --git a/cockatrice/src/interface/deck_sync/deck_sync_profile.h b/cockatrice/src/interface/deck_sync/deck_sync_profile.h new file mode 100644 index 000000000..ac63ab913 --- /dev/null +++ b/cockatrice/src/interface/deck_sync/deck_sync_profile.h @@ -0,0 +1,42 @@ +/** + * @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 + +/** + * @struct 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. + */ +struct DeckSyncProfile +{ + 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 diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp new file mode 100644 index 000000000..bfea3a7fa --- /dev/null +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.cpp @@ -0,0 +1,97 @@ +#include "deck_sync_sidecar.h" + +#include +#include +#include +#include +#include + +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(); + const DeckSyncRecord record{.guid = entry.value("guid").toString(), + .remoteId = entry.value("remoteId").toInt(-1), + .baseHash = entry.value("baseHash").toString(), + .lastSyncSecs = static_cast(entry.value("lastSync").toDouble()), + .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(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 DeckSyncSidecar::records() const +{ + return recordMap; +} diff --git a/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h new file mode 100644 index 000000000..7eb2db7ae --- /dev/null +++ b/cockatrice/src/interface/deck_sync/deck_sync_sidecar.h @@ -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 +#include + +/** @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 records() const; + +private: + QMap recordMap; +}; + +#endif // DECK_SYNC_SIDECAR_H diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index ffd4c28a1..4a59d615a 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -7,6 +7,7 @@ set(HEADERS libcockatrice/deck_list/tree/abstract_deck_list_node.h libcockatrice/deck_list/tree/deck_list_card_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_list.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/deck_list_card_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_list.cpp libcockatrice/deck_list/deck_list_history_manager.cpp diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.cpp new file mode 100644 index 000000000..31a6b622b --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.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 +#include +#include +#include + +namespace +{ +using ZoneCardKey = QPair; + +QMap zoneCardCounts(const DeckList &deck) +{ + QMap 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 oldCounts = zoneCardCounts(oldDeck); + const QMap newCounts = zoneCardCounts(newDeck); + + QSet keys(oldCounts.keyBegin(), oldCounts.keyEnd()); + keys.unite(QSet(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 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 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 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; +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.h new file mode 100644 index 000000000..2b4fb8732 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_diff.h @@ -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 +#include + +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 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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c8d3fbd59..32ee9b7f1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -407,6 +407,34 @@ cockatrice_add_test( 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. if(TARGET Qt6::lrelease) set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm") diff --git a/tests/deck_diff_test.cpp b/tests/deck_diff_test.cpp new file mode 100644 index 000000000..4ff0b556b --- /dev/null +++ b/tests/deck_diff_test.cpp @@ -0,0 +1,137 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +namespace +{ +DeckList makeDeck(std::initializer_list> mainCards, + std::initializer_list> 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(); +} diff --git a/tests/deck_sync_profile_test.cpp b/tests/deck_sync_profile_test.cpp new file mode 100644 index 000000000..fc6917559 --- /dev/null +++ b/tests/deck_sync_profile_test.cpp @@ -0,0 +1,63 @@ +#include "gtest/gtest.h" +#include +#include + +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(); +} diff --git a/tests/deck_sync_sidecar_test.cpp b/tests/deck_sync_sidecar_test.cpp new file mode 100644 index 000000000..4bd04be33 --- /dev/null +++ b/tests/deck_sync_sidecar_test.cpp @@ -0,0 +1,126 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +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(); +}