diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index ae5e92d6c..192029b23 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -177,7 +178,15 @@ bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data) // Data written, so reload the card database qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written"; - const auto reloadOk = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); + // Spoilers can introduce sets the config has never seen; the loader never + // runs checkUnknownSets() itself, so run it once the finished snapshot has + // been swapped in. + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [watcher] { + watcher->deleteLater(); + CardDatabaseManager::getInstance()->checkUnknownSets(); + }); + watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); })); // If the user has notifications enabled, let them know // when the database was last updated diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 15a41e7e1..08eccf1e4 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -133,6 +133,10 @@ SettingsCache::SettingsCache() // Migrate settings from global.ini to per-class INI files (runs at most once) SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); + // Repair gamefilters.ini keys renamed to camelCase by #7084 for profiles that migrated + // before that rename (idempotent, safe to run on every startup, portable builds included). + SettingsMigration::migrateGameFiltersToCamelCase(settingsPath); + shortcutsSettings = new ShortcutsSettings(settingsPath, this); cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this); serversSettings = new ServersSettings(settingsPath, this); diff --git a/cockatrice/src/interface/widgets/server/game_filter_configs.h b/cockatrice/src/interface/widgets/server/game_filter_configs.h index 1b7322c55..93ca010d8 100644 --- a/cockatrice/src/interface/widgets/server/game_filter_configs.h +++ b/cockatrice/src/interface/widgets/server/game_filter_configs.h @@ -17,7 +17,9 @@ struct GameFilterConfigs static constexpr int DEFAULT_MAX_PLAYERS_MAX = 99; bool hideBuddiesOnlyGames = false; - bool hideIgnoredUserGames = false; + // Defaults to true to match GameFiltersSettings::isHideIgnoredUserGames(), so freshly loaded + // filters compare equal to a default-constructed config. + bool hideIgnoredUserGames = true; bool hideFullGames = false; bool hideGamesThatStarted = false; bool hidePasswordProtectedGames = false; diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 27464e14e..6922bfe64 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -113,6 +113,17 @@ GameSelector::GameSelector(AbstractClient *_client, quickFilterToolBar->setVisible( SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar()); }); + // The quick-filter toolbar only updates the in-memory model, so persist every change. + // Otherwise each new GameSelector (room switch, reconnect, restart) would revert to stale + // saved values and e.g. hide full/started games the user had unchecked. The save is + // debounced so typing in the search field does not rewrite the settings file on every + // keystroke; a pending save is flushed in the destructor. + filterSaveDebounceTimer.setSingleShot(true); + filterSaveDebounceTimer.setInterval(500); + connect(&filterSaveDebounceTimer, &QTimer::timeout, this, + [this] { gameListProxyModel->saveFilterParameters(gameTypeMap); }); + connect(gameListProxyModel, &GamesProxyModel::filtersChanged, this, + [this] { filterSaveDebounceTimer.start(); }); } else { quickFilterToolBar = nullptr; } @@ -187,6 +198,18 @@ GameSelector::GameSelector(AbstractClient *_client, connect(client, &AbstractClient::removeFromListEventReceived, this, &GameSelector::processRemoveFromListEvent); } +GameSelector::~GameSelector() +{ + // Nothing else persists quick-filter toolbar edits: the toolbar only updates the in-memory model, + // and the debounced timer above is the sole writer for that path (actSetFilter/actClearFilter save + // synchronously, but the toolbar does not). Flush a pending debounced save so edits made within + // the debounce interval before destruction are not lost on room switch, reconnect, or close. + if (filterSaveDebounceTimer.isActive()) { + filterSaveDebounceTimer.stop(); + gameListProxyModel->saveFilterParameters(gameTypeMap); + } +} + void GameSelector::ignoreListReceived(const QList &) { gameListProxyModel->refresh(); diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h index 01e093b06..e3407abea 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.h +++ b/cockatrice/src/interface/widgets/server/game_selector.h @@ -4,6 +4,7 @@ #include "game_type_map.h" #include +#include #include // IWYU pragma: keep // ServerInfo_User appears by value and inside QList in slot signatures, so the @@ -148,6 +149,9 @@ private: const bool showFilters; /**< Determines whether filter buttons are displayed. */ GameTypeMap gameTypeMap; /**< Mapping of game types for the current room. */ + QTimer filterSaveDebounceTimer; /**< Delays persisting quick-filter edits so typing does not rewrite the settings + file on every keystroke. */ + /** * @brief Updates the widget title to reflect the current number of displayed games. * @@ -211,6 +215,7 @@ public: const bool restoresettings, const bool _showfilters, QWidget *parent = nullptr); + ~GameSelector() override; /** * @brief Updates UI text for translation/localization. diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 289dbee44..315d51d9e 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -523,6 +523,9 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const void GamesProxyModel::refresh() { +#if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)) + beginFilterChange(); +#endif #if (QT_VERSION >= QT_VERSION_CHECK(6, 10, 0)) endFilterChange(QSortFilterProxyModel::Direction::Rows); #else diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 4f363181e..815cec1d2 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -70,6 +70,7 @@ #include #include #include +#include #include #include #include @@ -640,10 +641,10 @@ void MainWindow::startupConfigCheck() const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET; - // checkUnknownSets() is intentionally deferred from the card database load - // (which runs in main() before MainWindow exists) so that - // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live - // receivers when emitted. + // The loader never runs checkUnknownSets() itself (see main.cpp): the + // front-loaded parse runs before MainWindow exists, so + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no + // receivers. MainWindow owns the check after startup. // On a clean install the onboarding wizard owns the first-run experience; // wait until it closes so the legacy "all sets enabled" welcome and the // Manage Sets dialog don't appear first. @@ -1137,18 +1138,35 @@ void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknow void MainWindow::cardDatabaseAllNewSetsEnabled() { - if (firstRunWizardActive || CardDatabaseManager::getInstance()->getCardList().isEmpty()) { - // The onboarding wizard owns the first-run messaging on a clean install, - // and with no card data there are no sets to have enabled. + auto *cardDatabase = CardDatabaseManager::getInstance(); + + // With no card data there is nothing to have enabled and nothing to reparse. + if (cardDatabase->getCardList().isEmpty()) { return; } - QMessageBox::information( - this, tr("Welcome"), - tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the card " - "database have been enabled.\nRead more about changing the set order or disabling specific sets and " - "consequent effects in the \"Manage Sets\" dialog.")); - actManageSets(); + // checkUnknownSets() enabled every set after the database had already been + // parsed under the (empty) first-run enablement, so the parser dropped every + // printing of every set. Reparse now that the sets are enabled, otherwise the + // deck editor's card database display stays empty until the next launch. + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [this, watcher] { + watcher->deleteLater(); + if (firstRunWizardActive) { + // The onboarding wizard owns the first-run messaging on a clean install. + return; + } + QMessageBox::information( + this, tr("Welcome"), + tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the " + "card " + "database have been enabled.\nRead more about changing the set order or disabling specific sets and " + "consequent effects in the \"Manage Sets\" dialog.")); + // The reload above replaces the set instances, so Manage Sets must open + // only after the finished snapshot has been swapped in. + actManageSets(); + }); + watcher->setFuture(QtConcurrent::run([cardDatabase] { cardDatabase->reloadCardDatabasesAndNotify(); })); } /* CARD UPDATER */ @@ -1321,7 +1339,19 @@ void MainWindow::exitCardDatabaseUpdate() cardUpdateProcess = nullptr; statusBar()->clearMessage(); - const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); + // This can be the first successful load of the session (e.g. the onboarding + // download), so it must own the set check for that load. + reloadCardDatabaseAndCheckSets(); +} + +void MainWindow::reloadCardDatabaseAndCheckSets() +{ + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [watcher] { + watcher->deleteLater(); + CardDatabaseManager::getInstance()->checkUnknownSets(); + }); + watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); })); } void MainWindow::cardUpdateError(QProcess::ProcessError err) @@ -1523,7 +1553,8 @@ void MainWindow::actAddCustomSet() QMessageBox::information( this, tr("Load sets/cards"), tr("The new sets/cards have been added successfully.\nCockatrice will now reload the card database.")); - const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); + // Imported files can introduce sets the config has never seen. + reloadCardDatabaseAndCheckSets(); } else { QMessageBox::warning(this, tr("Load sets/cards"), tr("Sets/cards failed to import.")); } diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 6b1186d3c..ee8873000 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -158,6 +158,14 @@ private: } void createCardUpdateProcess(bool background = false); void exitCardDatabaseUpdate(); + /** + * @brief Reloads the card database on a worker thread and runs checkUnknownSets() once the finished + * snapshot has been swapped in. + * + * The loader never runs checkUnknownSets() itself (see main.cpp); post-update loads that can introduce + * new sets must own the check, and it has to run after the queued snapshot swap, not on the worker. + */ + void reloadCardDatabaseAndCheckSets(); /** * @brief Tries to bring the open card database updater window to the front. * @return true when the updater acknowledged the request diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index b9cc8dabc..3f63efc3b 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -343,11 +343,12 @@ int main(int argc, char *argv[]) // contention that happens when the load runs alongside window construction. // The CardDatabaseModel populates from the already-loaded data in its // constructor, so the window appears fully populated with no startup lag. - // Note: checkUnknownSets() is deferred from the initial load to - // MainWindow::startupConfigCheck() so that - // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live - // receivers when emitted. Subsequent reloads (e.g. path changes) call - // checkUnknownSets() directly from the loader after the first load. + // Note: checkUnknownSets() is deliberately not run for this front-loaded + // parse. It would run before MainWindow exists, so + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no + // receivers, and enabling the sets up-front would suppress the first-run + // experience MainWindow::startupConfigCheck() and the onboarding wizard + // provide. Callers that load after startup own the check. CardDatabaseManager::getInstance()->loadCardDatabases(); MainWindow ui; diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.cpp b/libcockatrice_card/libcockatrice/card/database/card_database.cpp index 469c8d02c..7b0d5b2f9 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -80,15 +81,17 @@ void CardDatabase::reloadCardDatabasesAndNotify() { // An explicit reload bypasses the binary cache so the files on disk are // always re-read, even when their size/mtime hash still matches the cache. + // Models refresh from the swap's cardDatabaseReset in a single batch, so + // no cardDatabaseEnabledSetsChanged is emitted here: that rebuilt the + // 36k-card models a second time on the GUI thread, which froze startup + // after enabling new/custom sets. The follow-up unknown-sets check is + // queued to the GUI thread so it sees the swapped-in data (this may run + // on a worker via QtConcurrent::run); it is silent unless new sets + // appeared mid-reload. loadStatus = loader->loadCardDatabases(/* ignoreCache */ true); if (loadStatus == Ok) { - checkUnknownSets(); - // A reload reconstructs the exact same enabled-set state, so the cached - // set-name / alt-name data computed during construction is still valid. - // Skipping the per-card refresh avoids a full 36k-card recompute that - // would otherwise duplicate the work already done while building cards. - notifyEnabledSetsChanged(false); + QMetaObject::invokeMethod(this, &CardDatabase::checkUnknownSets, Qt::AutoConnection); } } @@ -207,15 +210,19 @@ void CardDatabase::checkUnknownSets() if (numUnknownSets > 0) { emit cardDatabaseNewSetsFound(numUnknownSets, unknownSetNames); } else { - _sets.markAllAsKnown(); + _sets.markAllAsKnownBatched(setPriorityController); } } else { // No set enabled. Probably this is the first time running trice - _sets.guessSortKeys(); + _sets.enableAllBatched(setPriorityController); _sets.sortByKey(); - _sets.enableAll(); - notifyEnabledSetsChanged(); + // No cardDatabaseEnabledSetsChanged here: the reload chained off + // cardDatabaseAllNewSetsEnabled re-parses with the sets enabled and its + // swap's cardDatabaseReset rebuilds the models in one batch. Emitting + // both back-to-back rebuilt the 36k-card models twice on the GUI thread, + // stalling startup. (When the card list is empty the slot returns early + // without reloading, but there is nothing to display either.) emit cardDatabaseAllNewSetsEnabled(); } } @@ -223,13 +230,13 @@ void CardDatabase::checkUnknownSets() void CardDatabase::enableAllUnknownSets() { auto _sets = getSetList(); - _sets.enableAllUnknown(); + _sets.enableAllUnknownBatched(setPriorityController); } void CardDatabase::markAllSetsAsKnown() { auto _sets = getSetList(); - _sets.markAllAsKnown(); + _sets.markAllAsKnownBatched(setPriorityController); } void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets) diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp index 194083615..00b21f88e 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp @@ -44,7 +44,13 @@ CardDatabaseLoader::CardDatabaseLoader(QObject *parent, // the finished snapshot is swapped into the live database on the GUI thread. // when SettingsCache's path changes, trigger reloads - connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] { loadCardDatabases(); }); + connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] { + loadCardDatabases(); + // This reload runs synchronously on the emitting (GUI) thread, so the + // finished snapshot is already swapped in when it returns: enable any + // sets the newly pointed-at database introduced. + database->checkUnknownSets(); + }); } CardDatabaseLoader::~CardDatabaseLoader() @@ -136,7 +142,23 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache) loadCardDatabase(p, data); } - if (!saveToCache(data, sourceHash)) { + // A snapshot parsed while no set is enabled has had every printing + // stripped by the parsers, and the cache reader re-derives enablement + // from the priority controller, so such an entry can never be reused + // meaningfully. Skip writing it so a first-run (blank config) load + // cannot poison the cache with an empty snapshot. The check mirrors + // what the parsers saw: any set still reporting enabled means the + // snapshot is complete for the current enablement. + bool anySetEnabled = false; + for (const CardSetPtr &set : data.sets) { + if (set && set->getEnabled()) { + anySetEnabled = true; + break; + } + } + if (!data.sets.isEmpty() && !anySetEnabled) { + qCInfo(CardDatabaseLoadingLog) << "Skipping binary cache: no set is enabled yet, snapshot was stripped"; + } else if (!saveToCache(data, sourceHash)) { qCWarning(CardDatabaseLoadingLog) << "Failed to write binary cache to" << cachePath(); } } @@ -148,18 +170,15 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache) database->refreshCachedReverseRelatedCards(data.cards); qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success"; + // NOTE: checkUnknownSets() is intentionally NOT called here. During the + // front-loaded parse in main() MainWindow does not exist yet, so + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would be + // emitted with no receivers, and enabling the sets up-front would + // suppress the first-run experience MainWindow::startupConfigCheck() + // and the onboarding wizard are designed around. Callers that load + // after startup own the check (and any reload it triggers). emit databaseDataReady(std::move(data)); emit loadingFinished(); - // During the front-loaded parse in main() this runs before MainWindow - // exists, so cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled - // would be emitted with no receivers. Skip the check on the first load; - // MainWindow::startupConfigCheck() calls checkUnknownSets() once its - // signal connections are live. On subsequent reloads (e.g. path change) - // MainWindow exists and signals have live receivers. - if (initialLoadComplete) { - database->checkUnknownSets(); - } - initialLoadComplete = true; } else { qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed"; emit loadingFailed(); // bring up the settings dialog diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h index fffd31d2f..bfbaa0f51 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h @@ -179,7 +179,6 @@ private: QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */ QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */ - bool initialLoadComplete = false; /**< Set after the first successful load. */ }; #endif // COCKATRICE_CARD_DATABASE_LOADER_H diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.h b/libcockatrice_card/libcockatrice/card/set/card_set.h index 538d78852..b9ac74e2d 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.h +++ b/libcockatrice_card/libcockatrice/card/set/card_set.h @@ -227,6 +227,10 @@ public: * @param _isknown True if known, false if unknown. */ void setIsKnown(bool _isknown); + void setIsKnownInMemory(bool _isknown) + { + isknown = _isknown; + } /** * @brief Determines whether the set has incomplete metadata and should be ignored. diff --git a/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp b/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp index 2e030adab..c4faf1c4c 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp @@ -1,9 +1,11 @@ #include "card_set_list.h" #include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/interfaces/interface_card_set_priority_controller.h" #include #include +#include #include #include @@ -62,60 +64,87 @@ QStringList CardSetList::getUnknownSetsNames() return sets; } -void CardSetList::enableAllUnknown() +void CardSetList::enableAllUnknownBatched(ICardSetPriorityController *controller) { - for (int i = 0; i < size(); ++i) { - CardSetPtr set = at(i); - if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) { - set->setIsKnown(true); - set->setEnabled(true); - } else if (set && set->getIsKnownIgnored() && !set->getEnabled()) { - set->setEnabled(true); - } - } -} - -void CardSetList::enableAll() -{ - for (int i = 0; i < size(); ++i) { - CardSetPtr set = at(i); - - if (set == nullptr) { - // qCWarning(CardInfoLog) << "enabledAll has null"; - continue; - } - - if (!set->getIsKnownIgnored()) { - set->setIsKnown(true); - } - - set->setEnabled(true); - } -} - -void CardSetList::markAllAsKnown() -{ - for (int i = 0; i < size(); ++i) { - CardSetPtr set = at(i); - if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) { - set->setIsKnown(true); - set->setEnabled(false); - } else if (set && set->getIsKnownIgnored() && !set->getEnabled()) { - set->setEnabled(true); - } - } -} - -void CardSetList::guessSortKeys() -{ - defaultSort(); + QVector saveData; for (int i = 0; i < size(); ++i) { CardSetPtr set = at(i); if (set.isNull()) { - // qCWarning(CardInfoLog) << "guessSortKeys set is null"; continue; } - set->setSortKey(i); + + bool changed = false; + if (!set->getIsKnown() && !set->getIsKnownIgnored()) { + set->setIsKnownInMemory(true); + set->setEnabledInMemory(true); + changed = true; + } else if (set->getIsKnownIgnored() && !set->getEnabled()) { + set->setEnabledInMemory(true); + changed = true; + } + if (changed) { + saveData.append( + {set->getShortName(), static_cast(set->getSortKey()), true, set->getIsKnown()}); + } + } + + if (!saveData.isEmpty()) { + controller->saveSets(saveData); + } +} + +void CardSetList::enableAllBatched(ICardSetPriorityController *controller) +{ + defaultSort(); + + QVector saveData; + saveData.reserve(size()); + for (int i = 0; i < size(); ++i) { + CardSetPtr set = at(i); + if (set.isNull()) { + continue; + } + + const auto sortKey = static_cast(i); + set->setSortKeyInMemory(sortKey); + if (!set->getIsKnownIgnored()) { + set->setIsKnownInMemory(true); + } + set->setEnabledInMemory(true); + saveData.append({set->getShortName(), sortKey, true, set->getIsKnown()}); + } + + if (!saveData.isEmpty()) { + controller->saveSets(saveData); + } +} + +void CardSetList::markAllAsKnownBatched(ICardSetPriorityController *controller) +{ + QVector saveData; + for (int i = 0; i < size(); ++i) { + CardSetPtr set = at(i); + if (set.isNull()) { + continue; + } + + bool changed = false; + if (!set->getIsKnown() && !set->getIsKnownIgnored()) { + set->setIsKnownInMemory(true); + set->setEnabledInMemory(false); + changed = true; + } else if (set->getIsKnownIgnored() && !set->getEnabled()) { + set->setEnabledInMemory(true); + changed = true; + } + if (changed) { + saveData.append({set->getShortName(), static_cast(set->getSortKey()), set->getEnabled(), + set->getIsKnown()}); + } + } + + if (!saveData.isEmpty()) { + controller->saveSets(saveData); } } diff --git a/libcockatrice_card/libcockatrice/card/set/card_set_list.h b/libcockatrice_card/libcockatrice/card/set/card_set_list.h index b9c7b2a7a..f31710b33 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set_list.h +++ b/libcockatrice_card/libcockatrice/card/set/card_set_list.h @@ -40,36 +40,37 @@ public: void sortByKey(); /** - * @brief Reassigns sort keys based on the current order. + * @brief Enables every set with fresh sort keys, persisting in one flush. * - * Calls defaultSort() and then assigns sequential sort keys - * to all sets according to their resulting positions, replacing - * any existing sort keys to ensure consistent ordering. + * Assigns sequential sort keys in default-sorted order and marks every set + * known and enabled, writing all records through the controller's batched + * saveSets() in a single settings flush instead of ~3 QSettings rewrite + * cycles per set, which froze the GUI thread for seconds on a first run + * with a full database. + * @param controller The set priority controller to persist the records with. */ - void guessSortKeys(); + void enableAllBatched(ICardSetPriorityController *controller); /** - * @brief Enables all sets that are unknown or ignored. + * @brief Enables all sets that are unknown or ignored, persisting in one flush. * * Sets that are not marked as known and not ignored are marked as known * and enabled. Ignored-known sets are also enabled, but remain ignored. + * Only changed sets are written, so the common case of a few new sets costs + * a single settings flush no matter how large the database is. + * @param controller The set priority controller to persist the records with. */ - void enableAllUnknown(); + void enableAllUnknownBatched(ICardSetPriorityController *controller); /** - * @brief Enables all sets in the list. - * - * Equivalent to calling `setEnabled(true)` on each entry. - */ - void enableAll(); - - /** - * @brief Marks all sets as known and adjusts their enabled state. + * @brief Marks all sets as known and adjusts their enabled state, persisting in one flush. * * Unknown, non-ignored sets become known and disabled. * Ignored-known sets are enabled if they were previously disabled. + * Only changed sets are written; when nothing is unknown no flush happens at all. + * @param controller The set priority controller to persist the records with. */ - void markAllAsKnown(); + void markAllAsKnownBatched(ICardSetPriorityController *controller); /** * @brief Counts the number of sets that are currently enabled. diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h index 6b3e684d7..746ac9490 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h @@ -12,6 +12,7 @@ public: QString shortName; unsigned int sortKey; bool enabled; + bool isKnown = false; }; /** @@ -57,6 +58,14 @@ public: */ virtual QStringList getEnabledSetNames() const = 0; + /** + * @brief Persists per-set options for every entry in a single settings flush. + * + * Each individual setSortKey/setEnabled/setIsKnown call re-opens and re-syncs + * the whole .ini when backed by QSettings, so persisting hundreds of sets + * one option at a time freezes the GUI thread for seconds. Callers that touch + * more than a handful of sets must bundle the records and call this once. + */ virtual void saveSets(const QVector &data) = 0; }; diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp index 1bc1b9e38..a0b9c1061 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp @@ -266,7 +266,7 @@ void SetsModel::save(CardDatabase *db) const bool enabled = enabledSets.contains(sets[i]); sets[i]->setSortKeyInMemory(sortKey); sets[i]->setEnabledInMemory(enabled); - saveData.append({sets[i]->getShortName(), sortKey, enabled}); + saveData.append({sets[i]->getShortName(), sortKey, enabled, sets[i]->getIsKnown()}); } db->getPriorityController()->saveSets(saveData); diff --git a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp index acac1125f..49bcb042b 100644 --- a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp @@ -110,6 +110,7 @@ void CardDatabaseSettings::saveSets(const QVector &renamedGameFilterKeys() +{ + static const QMap keyMap = { + {"hide_buddies_only_games", "hideBuddiesOnlyGames"}, + {"hide_full_games", "hideFullGames"}, + {"hide_games_that_started", "hideGamesThatStarted"}, + {"hide_password_protected_games", "hidePasswordProtectedGames"}, + {"hide_ignored_user_games", "hideIgnoredUserGames"}, + {"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"}, + {"hide_open_decklist_games", "hideOpenDecklistGames"}, + {"game_name_filter", "gameNameFilter"}, + {"creator_name_filter", "hostNameFilter"}, + {"min_players", "minPlayers"}, + {"max_players", "maxPlayers"}, + {"max_game_age_time", "maxGameAgeTime"}, + {"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"}, + {"show_spectator_password_protected", "showSpectatorPasswordProtected"}, + {"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"}, + {"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"}, + }; + return keyMap; +} + static void migrateLegacyGameFilters(const QString &settingsPath) { QSettings legacySetting; @@ -652,24 +680,7 @@ static void migrateLegacyGameFilters(const QString &settingsPath) return; } - const QMap filterKeyMap = { - {"hide_buddies_only_games", "hideBuddiesOnlyGames"}, - {"hide_full_games", "hideFullGames"}, - {"hide_games_that_started", "hideGamesThatStarted"}, - {"hide_password_protected_games", "hidePasswordProtectedGames"}, - {"hide_ignored_user_games", "hideIgnoredUserGames"}, - {"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"}, - {"hide_open_decklist_games", "hideOpenDecklistGames"}, - {"game_name_filter", "gameNameFilter"}, - {"creator_name_filter", "creatorNameFilter"}, - {"min_players", "minPlayers"}, - {"max_players", "maxPlayers"}, - {"max_game_age_time", "maxGameAgeTime"}, - {"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"}, - {"show_spectator_password_protected", "showSpectatorPasswordProtected"}, - {"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"}, - {"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"}, - }; + const QMap &filterKeyMap = renamedGameFilterKeys(); QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); for (auto it = filterKeyMap.constBegin(); it != filterKeyMap.constEnd(); ++it) { @@ -686,6 +697,68 @@ static void migrateLegacyGameFilters(const QString &settingsPath) legacySetting.endGroup(); } +static const QString LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX = QStringLiteral("filter_games/game_type/"); +static const QString GAME_FILTERS_GAME_TYPE_PREFIX = QStringLiteral("filter_games/gameType/"); + +static bool gameFiltersCamelCaseMigrationPending(const QString &settingsPath) +{ + if (!QFile::exists(settingsPath + "gamefilters.ini")) { + return false; + } + + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + const QMap &keyMap = renamedGameFilterKeys(); + for (auto it = keyMap.constBegin(); it != keyMap.constEnd(); ++it) { + if (filtersIni.contains("filter_games/" + it.key()) && !filtersIni.contains("filter_games/" + it.value())) { + return true; + } + } + // Orphaned by #7084, which migrated the legacy host-name filter to a key nothing reads. + if (filtersIni.contains("filter_games/creatorNameFilter") && !filtersIni.contains("filter_games/hostNameFilter")) { + return true; + } + for (const auto &key : filtersIni.allKeys()) { + if (key.startsWith(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX) && + !filtersIni.contains(GAME_FILTERS_GAME_TYPE_PREFIX + + key.mid(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX.size()))) { + return true; + } + } + return false; +} + +bool SettingsMigration::migrateGameFiltersToCamelCase(const QString &settingsPath) +{ + if (!gameFiltersCamelCaseMigrationPending(settingsPath)) { + return false; + } + + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + const QMap &keyMap = renamedGameFilterKeys(); + for (auto it = keyMap.constBegin(); it != keyMap.constEnd(); ++it) { + const QString oldKey = "filter_games/" + it.key(); + const QString newKey = "filter_games/" + it.value(); + if (filtersIni.contains(oldKey) && !filtersIni.contains(newKey)) { + filtersIni.setValue(newKey, filtersIni.value(oldKey)); + } + } + if (filtersIni.contains("filter_games/creatorNameFilter") && !filtersIni.contains("filter_games/hostNameFilter")) { + filtersIni.setValue("filter_games/hostNameFilter", filtersIni.value("filter_games/creatorNameFilter")); + } + for (const auto &key : filtersIni.allKeys()) { + if (key.startsWith(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX)) { + const QString newKey = GAME_FILTERS_GAME_TYPE_PREFIX + key.mid(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX.size()); + if (!filtersIni.contains(newKey)) { + filtersIni.setValue(newKey, filtersIni.value(key)); + } + } + } + // The old keys are deliberately kept: pre-#7084 builds still read them, so removing them + // would break downgrades, and nothing in the current code reads them anymore. + filtersIni.sync(); + return true; +} + bool SettingsMigration::migrateLegacySettings(const QString &settingsPath) { QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); @@ -744,6 +817,10 @@ bool SettingsMigration::isMigrationPending(const QString &settingsPath) return true; } + if (gameFiltersCamelCaseMigrationPending(settingsPath)) { + return true; + } + QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); if (personalIni.value("migration/legacy_complete", false).toBool()) { return false; diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.h b/libcockatrice_settings/libcockatrice/settings/settings_migration.h index 78f9c8a3f..0a706af98 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_migration.h +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.h @@ -9,6 +9,15 @@ public: static bool migrateSettingsFromGlobalIni(const QString &settingsPath); static bool migrateLegacySettings(const QString &settingsPath); + /** + * Moves pre-camelCase gamefilters.ini keys (snake_case, game_type/) to the camelCase + * keys used since #7084, for profiles that already migrated before that rename and therefore + * never pass through migrateLegacySettings again. Copies values only where the new key is + * absent, never deletes the old keys, and returns true when anything was copied. Idempotent, + * so it runs on every startup. + */ + static bool migrateGameFiltersToCamelCase(const QString &settingsPath); + /** * Returns true when at least one of the two migrations would still do work, i.e. when a * backup of the settings directory is worth taking before they run. diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 1ac259d9b..1b48d934b 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -159,16 +159,19 @@ public: void setSortKey(QString shortName, unsigned int sortKey) override { + ++individualWrites; setOptions[shortName].sortKey = sortKey; } void setEnabled(QString shortName, bool enabled) override { + ++individualWrites; setOptions[shortName].enabled = enabled; } void setIsKnown(QString shortName, bool isknown) override { + ++individualWrites; setOptions[shortName].isKnown = isknown; } @@ -206,12 +209,17 @@ public: void saveSets(const QVector &data) override { + ++batchedWrites; for (const SetSaveData &entry : data) { setOptions[entry.shortName].sortKey = entry.sortKey; setOptions[entry.shortName].enabled = entry.enabled; + setOptions[entry.shortName].isKnown = entry.isKnown; } } + int individualWrites = 0; ///< Per-set/per-option controller writes (the slow path). + int batchedWrites = 0; ///< Batched saveSets() flushes (the fast path). + private: QHash setOptions; }; @@ -375,6 +383,131 @@ TEST_F(CardDatabaseCacheInvalidationTest, ExplicitReloadBypassesCache) EXPECT_FALSE(db.getCardList().value("Cat")->getSets().contains("CAT")); EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("DOG")); } + +TEST_F(CardDatabaseCacheInvalidationTest, FirstRunWithNoSetsEnabledSkipsCacheWrite) +{ + // A blank config: the parser discovers every set, but none of them has an + // ini entry, so all of them default to disabled (first run). + SeededSetPriorityController blankController; + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &blankController); + + db.loadCardDatabases(); + ASSERT_FALSE(db.getCardList().value("Cat").isNull()) << "cards are parsed even while their sets are disabled"; + EXPECT_TRUE(db.getCardList().value("Cat")->getSets().isEmpty()) + << "no printings survive an all-sets-disabled parse"; + EXPECT_TRUE(cacheContents().isEmpty()) << "a stripped first-run snapshot must not be cached"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, EnablingAllSetsAfterFirstRunRestoresPrintings) +{ + SeededSetPriorityController blankController; + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &blankController); + + db.loadCardDatabases(); + ASSERT_TRUE(cacheContents().isEmpty()); + + // This mirrors what checkUnknownSets() does on a first run: enable every + // set, then reload so the parser keeps the printings and the complete + // snapshot gets cached under the enabled-set hash. + for (const CardSetPtr &set : db.getSetList()) { + blankController.setEnabled(set->getShortName(), true); + } + db.reloadCardDatabasesAndNotify(); + + ASSERT_FALSE(db.getCardList().value("Cat").isNull()); + EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("CAT")); + EXPECT_FALSE(cacheContents().isEmpty()) << "the enabled-set snapshot must be cached"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, CheckUnknownSetsBatchesFirstRunEnableAll) +{ + // Same first-run starting point as above, but driven through the real + // checkUnknownSets() path, which must persist the enable-all with a single + // batched saveSets() instead of per-set/per-option controller writes. + SeededSetPriorityController blankController; + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &blankController); + + db.loadCardDatabases(); + ASSERT_TRUE(db.getSetList().getEnabledSetsNum() == 0); + + db.checkUnknownSets(); + + EXPECT_EQ(blankController.individualWrites, 0) << "first-run enable-all must not write per set"; + EXPECT_EQ(blankController.batchedWrites, 1) << "first-run enable-all must persist with a single flush"; + for (const CardSetPtr &set : db.getSetList()) { + EXPECT_TRUE(set->getEnabled()) << set->getShortName().toStdString(); + EXPECT_TRUE(blankController.isEnabled(set->getShortName())) << set->getShortName().toStdString(); + if (!set->getIsKnownIgnored()) { + EXPECT_TRUE(set->getIsKnown()) << set->getShortName().toStdString(); + EXPECT_TRUE(blankController.isKnown(set->getShortName())) << set->getShortName().toStdString(); + } + } + + // The follow-up reload re-parses with the sets enabled, restoring printings. + db.reloadCardDatabasesAndNotify(); + ASSERT_FALSE(db.getCardList().value("Cat").isNull()); + EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("CAT")); +} + +TEST_F(CardDatabaseCacheInvalidationTest, EnableAllUnknownSetsIsBatched) +{ + // The "new sets found" dialog path: enabling the unknown delta must persist + // with a single flush no matter how many sets the database holds, so users + // with hundreds of custom sets don't stall the GUI thread. Built directly + // on CardSet objects because the fixture sets carry no metadata (and would + // all count as ignored). + SeededSetPriorityController controller; + CardSetList sets; + sets.append( + CardSet::newInstance(&controller, "AAA", "Alpha Set", "core", QDate(2020, 1, 1), CardSet::PriorityPrimary)); + sets.append( + CardSet::newInstance(&controller, "BBB", "Beta Set", "expansion", QDate(2021, 6, 1), CardSet::PriorityPrimary)); + sets.append(CardSet::newInstance(&controller, "XXX")); // no metadata: ignored + + sets.enableAllUnknownBatched(&controller); + + EXPECT_EQ(controller.individualWrites, 0) << "unknown-set enable must not write per set"; + EXPECT_EQ(controller.batchedWrites, 1) << "unknown-set enable must persist with a single flush"; + for (const CardSetPtr &set : sets) { + EXPECT_TRUE(set->getEnabled()) << set->getShortName().toStdString(); + EXPECT_TRUE(controller.isEnabled(set->getShortName())) << set->getShortName().toStdString(); + if (set->getIsKnownIgnored()) { + EXPECT_FALSE(set->getIsKnown()) << set->getShortName().toStdString(); + } else { + EXPECT_TRUE(set->getIsKnown()) << set->getShortName().toStdString(); + EXPECT_TRUE(controller.isKnown(set->getShortName())) << set->getShortName().toStdString(); + } + } +} + +TEST_F(CardDatabaseCacheInvalidationTest, MarkAllSetsAsKnownIsBatched) +{ + // The dialog's "No" path: same single-flush requirement as enabling. + SeededSetPriorityController controller; + CardSetList sets; + sets.append( + CardSet::newInstance(&controller, "AAA", "Alpha Set", "core", QDate(2020, 1, 1), CardSet::PriorityPrimary)); + sets.append( + CardSet::newInstance(&controller, "BBB", "Beta Set", "expansion", QDate(2021, 6, 1), CardSet::PriorityPrimary)); + sets.append(CardSet::newInstance(&controller, "XXX")); // no metadata: ignored + + sets.markAllAsKnownBatched(&controller); + + EXPECT_EQ(controller.individualWrites, 0) << "mark-known must not write per set"; + EXPECT_EQ(controller.batchedWrites, 1) << "mark-known must persist with a single flush"; + EXPECT_TRUE(controller.isKnown("AAA")); + EXPECT_TRUE(controller.isKnown("BBB")); + EXPECT_FALSE(controller.isEnabled("AAA")); + EXPECT_FALSE(controller.isEnabled("BBB")); + // Ignored sets keep their known state but are enabled. + EXPECT_TRUE(controller.isEnabled("XXX")); +} } // namespace int main(int argc, char **argv) diff --git a/tests/settings/settings_migration_test.cpp b/tests/settings/settings_migration_test.cpp index 440e49b74..2c4dcf7a9 100644 --- a/tests/settings/settings_migration_test.cpp +++ b/tests/settings/settings_migration_test.cpp @@ -705,6 +705,71 @@ TEST_F(SettingsMigrationTest, LegacyMigrationCamelCasesKeys) ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameType/deadbeef"), QVariant(true)); } +TEST_F(SettingsMigrationTest, LegacyMigrationMapsCreatorNameFilterToHostNameFilter) +{ + { + QSettings nativeSettings; + nativeSettings.setValue("filter_games/creator_name_filter", QStringList({"Alice", "Bob"})); + nativeSettings.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateLegacySettings(settingsPath)); + + ASSERT_TRUE(fileExists("gamefilters.ini")); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hostNameFilter"), QVariant(QStringList({"Alice", "Bob"}))); +} + +TEST_F(SettingsMigrationTest, GameFiltersIniSnakeCaseMigratesToCamelCase) +{ + { + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + filtersIni.setValue("filter_games/hide_full_games", true); + filtersIni.setValue("filter_games/hide_games_that_started", true); + filtersIni.setValue("filter_games/game_name_filter", "test game"); + filtersIni.setValue("filter_games/creator_name_filter", QStringList({"Alice"})); + filtersIni.setValue("filter_games/game_type/deadbeef", true); + filtersIni.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath)); + + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideFullGames"), QVariant(true)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideGamesThatStarted"), QVariant(true)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameNameFilter"), QVariant("test game")); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hostNameFilter"), QVariant(QStringList({"Alice"}))); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameType/deadbeef"), QVariant(true)); + + // Idempotent: a second run has nothing left to do + ASSERT_FALSE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath)); +} + +TEST_F(SettingsMigrationTest, GameFiltersIniOrphanCreatorNameFilterMigratesToHostName) +{ + { + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + filtersIni.setValue("filter_games/creatorNameFilter", QStringList({"Alice"})); + filtersIni.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hostNameFilter"), QVariant(QStringList({"Alice"}))); + ASSERT_FALSE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath)); +} + +TEST_F(SettingsMigrationTest, GameFiltersIniKeepsNewerCamelCaseValues) +{ + { + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + filtersIni.setValue("filter_games/hide_full_games", true); + filtersIni.setValue("filter_games/hideFullGames", false); + filtersIni.sync(); + } + + // Nothing to do: the current-format key already exists and must win + ASSERT_FALSE(SettingsMigration::migrateGameFiltersToCamelCase(settingsPath)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideFullGames"), QVariant(false)); +} + TEST_F(SettingsMigrationTest, LegacyMigrationEmptyNativeFormatWritesSentinel) { QSettings().clear();