Fix user card games for offline users and stale requests (#7738)

This commit is contained in:
Bryce! 2026-11-03 23:29:51 -05:00 • committed by GitHub
parent 7768416091
commit 94f7de42f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 550 additions and 76 deletions

View file

@ -449,6 +449,8 @@ set(cockatrice_SOURCES
src/interface/intents/intent_open_shared_deck.h src/interface/intents/intent_open_shared_deck.h
src/interface/intents/url_parser.cpp src/interface/intents/url_parser.cpp
src/interface/intents/url_parser.h src/interface/intents/url_parser.h
src/interface/widgets/server/user/user_games_model.cpp
src/interface/widgets/server/user/user_games_model.h
src/interface/widgets/server/user/user_info_popup.cpp src/interface/widgets/server/user/user_info_popup.cpp
src/interface/widgets/server/user/user_info_popup.h src/interface/widgets/server/user/user_info_popup.h
) )

View file

@ -0,0 +1,149 @@
#include "user_games_model.h"
#include <QVariant>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/session_commands.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <utility>
UserGamesModel::UserGamesModel(AbstractClient *_client, int requestTimeoutMs, QObject *parent)
: QAbstractListModel(parent), client(_client)
{
timeout.setSingleShot(true);
timeout.setInterval(requestTimeoutMs);
connect(&timeout, &QTimer::timeout, this, [this] {
invalidateRequest();
setState(State::Error);
});
connected = client->getStatus() == StatusLoggedIn;
connect(client, &AbstractClient::statusChanged, this, [this](ClientStatus status) {
// AbstractClient::setStatus emits this signal while holding the (non-recursive)
// status mutex, which getStatus() would try to lock again. Track the emitted
// value here, and read this cache from canRefresh() for the same reason.
connected = status == StatusLoggedIn;
if (!connected) {
invalidateRequest();
replaceGames({});
setState(online ? State::Disconnected : State::Offline);
} else if (online) {
refresh();
}
});
connect(client, &QObject::destroyed, this, [this] {
client = nullptr;
connected = false;
invalidateRequest();
replaceGames({});
setState(online ? State::Disconnected : State::Offline);
});
}
int UserGamesModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : games.size();
}
QVariant UserGamesModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.model() != this || index.column() != 0 || index.row() < 0 ||
index.row() >= games.size() || role != GameRole) {
return {};
}
return QVariant::fromValue(games.at(index.row()));
}
void UserGamesModel::setUser(const QString &name, bool isOnline)
{
userName = name;
online = isOnline;
refresh();
}
UserGamesModel::State UserGamesModel::getState() const
{
return state;
}
bool UserGamesModel::canRefresh() const
{
return online && !userName.isEmpty() && client && connected && state != State::Loading;
}
void UserGamesModel::invalidateRequest()
{
++requestGeneration;
// The client owns pending commands. Detach our interest without deleting
// an object that may already have moved to the client's thread.
disconnect(responseConnection);
responseConnection = {};
timeout.stop();
}
void UserGamesModel::replaceGames(QList<ServerInfo_Game> replacement)
{
beginResetModel();
games = std::move(replacement);
endResetModel();
}
void UserGamesModel::setState(State newState)
{
state = newState;
emit stateChanged();
}
void UserGamesModel::refresh()
{
invalidateRequest();
const quint64 request = requestGeneration;
replaceGames({});
if (!online || userName.isEmpty()) {
setState(State::Offline);
return;
}
if (!client || !connected) {
setState(State::Disconnected);
return;
}
setState(State::Loading);
// State listeners may select another user or disconnect the client.
if (request != requestGeneration) {
return;
}
Command_GetGamesOfUser command;
command.set_user_name(userName.toStdString());
PendingCommand *pending = client->prepareSessionCommand(command);
responseConnection = connect(pending, &PendingCommand::finished, this,
[this, request](const Response &response) { finishRequest(response, request); });
timeout.start();
client->sendCommand(pending);
}
void UserGamesModel::finishRequest(const Response &response, quint64 request)
{
if (request != requestGeneration) {
return;
}
invalidateRequest();
const quint64 completion = requestGeneration;
if (response.response_code() != Response::RespOk || !response.HasExtension(Response_GetGamesOfUser::ext)) {
setState(State::Error);
return;
}
const auto &result = response.GetExtension(Response_GetGamesOfUser::ext);
QList<ServerInfo_Game> replacement;
replacement.reserve(result.game_list_size());
for (const auto &game : result.game_list()) {
replacement.append(game);
}
replaceGames(std::move(replacement));
if (completion != requestGeneration) {
return;
}
setState(games.isEmpty() ? State::Empty : State::Ready);
}

View file

@ -0,0 +1,63 @@
#ifndef COCKATRICE_USER_GAMES_MODEL_H
#define COCKATRICE_USER_GAMES_MODEL_H
#include <QAbstractListModel>
#include <QList>
#include <QPointer>
#include <QString>
#include <QTimer>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
class AbstractClient;
class Response;
/** Current user's games and the lifetime of the request that supplies them. */
class UserGamesModel : public QAbstractListModel
{
Q_OBJECT
public:
enum class State
{
Offline,
Disconnected,
Loading,
Ready,
Empty,
Error
};
static constexpr int DefaultRequestTimeoutMs = 15000;
static constexpr int GameRole = Qt::UserRole + 10;
explicit UserGamesModel(AbstractClient *client,
int requestTimeoutMs = DefaultRequestTimeoutMs,
QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
void setUser(const QString &name, bool isOnline);
void refresh();
State getState() const;
bool canRefresh() const;
signals:
void stateChanged();
private:
void invalidateRequest();
void replaceGames(QList<ServerInfo_Game> replacement);
void finishRequest(const Response &response, quint64 request);
void setState(State newState);
QPointer<AbstractClient> client;
QString userName;
bool online = false;
bool connected = false;
State state = State::Offline;
QList<ServerInfo_Game> games;
quint64 requestGeneration = 0;
QMetaObject::Connection responseConnection;
QTimer timeout;
};
#endif // COCKATRICE_USER_GAMES_MODEL_H

View file

@ -6,10 +6,9 @@
#include "../../interface/widgets/tabs/tab_supervisor.h" #include "../../interface/widgets/tabs/tab_supervisor.h"
#include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" #include "libcockatrice/protocol/pb/serverinfo_game.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include "libcockatrice/protocol/pb/session_commands.pb.h" #include "user_games_model.h"
#include "user_level.h" #include "user_level.h"
#include "user_list_painter.h" #include "user_list_painter.h"
@ -34,6 +33,7 @@
#include <QPainter> #include <QPainter>
#include <QPainterPath> #include <QPainterPath>
#include <QPen> #include <QPen>
#include <QPersistentModelIndex>
#include <QPoint> #include <QPoint>
#include <QPushButton> #include <QPushButton>
#include <QRect> #include <QRect>
@ -41,8 +41,6 @@
#include <QSharedPointer> #include <QSharedPointer>
#include <QSize> #include <QSize>
#include <QSizePolicy> #include <QSizePolicy>
#include <QStandardItem>
#include <QStandardItemModel>
#include <QStringLiteral> #include <QStringLiteral>
#include <QStyle> #include <QStyle>
#include <QStyleOptionViewItem> #include <QStyleOptionViewItem>
@ -52,8 +50,6 @@
#include <QtGlobal> #include <QtGlobal>
#include <libcockatrice/card/database/card_database_manager.h> #include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h> #include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/card_ref.h> #include <libcockatrice/utility/card_ref.h>
#include <libcockatrice/utility/qt_utils.h> #include <libcockatrice/utility/qt_utils.h>
#include <qnumeric.h> #include <qnumeric.h>
@ -427,6 +423,7 @@ void UserInfoPopup::buildUi()
refreshBtn = new QPushButton(QStringLiteral("↻"), this); refreshBtn = new QPushButton(QStringLiteral("↻"), this);
refreshBtn->setFixedSize(20, 20); refreshBtn->setFixedSize(20, 20);
refreshBtn->setFlat(true); refreshBtn->setFlat(true);
refreshBtn->setToolTip(tr("Refresh games"));
connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames);
gh->addWidget(refreshBtn); gh->addWidget(refreshBtn);
root->addLayout(gh); root->addLayout(gh);
@ -437,7 +434,8 @@ void UserInfoPopup::buildUi()
root->addWidget(gamesStatus); root->addWidget(gamesStatus);
// Games list // Games list
gamesModel = new QStandardItemModel(this); gamesModel = new UserGamesModel(client, UserGamesModel::DefaultRequestTimeoutMs, this);
connect(gamesModel, &UserGamesModel::stateChanged, this, &UserInfoPopup::updateGamesDisplay);
gamesView = new QListView(this); gamesView = new QListView(this);
gamesView->setModel(gamesModel); gamesView->setModel(gamesModel);
gamesView->setItemDelegate(new PopupGameDelegate(gamesView)); gamesView->setItemDelegate(new PopupGameDelegate(gamesView));
@ -645,13 +643,21 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored)
{ {
rebuildActionButtons(userInfo, online, isBuddy, isIgnored); currentUserInfo = userInfo;
adjustSize(); currentBuddy = isBuddy;
currentIgnored = isIgnored;
if (currentOnline != online) {
setOnline(online);
} else {
refreshHeader();
rebuildActionButtons(userInfo, online, isBuddy, isIgnored);
adjustSize();
}
} }
void UserInfoPopup::onGamesContextMenu(const QPoint &pos) void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
{ {
const QModelIndex idx = gamesView->indexAt(pos); const QPersistentModelIndex idx = gamesView->indexAt(pos);
if (!idx.isValid()) { if (!idx.isValid()) {
return; return;
} }
@ -679,7 +685,7 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
} }
const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos)); const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos));
if (!chosen) { if (!chosen || !idx.isValid()) {
return; return;
} }
@ -716,6 +722,8 @@ void UserInfoPopup::showForUser(const QString &userName,
currentUser = userName; currentUser = userName;
currentUserInfo = userInfo; currentUserInfo = userInfo;
currentOnline = online; currentOnline = online;
currentBuddy = isBuddy;
currentIgnored = isIgnored;
// Header // Header
refreshHeader(); refreshHeader();
@ -723,88 +731,71 @@ void UserInfoPopup::showForUser(const QString &userName,
// Actions // Actions
rebuildActionButtons(userInfo, online, isBuddy, isIgnored); rebuildActionButtons(userInfo, online, isBuddy, isIgnored);
// Games list reset
gamesModel->clear();
gamesView->hide();
gamesStatus->setText(tr("Loading games…"));
gamesStatus->show();
// Close button, top right corner, above everything // Close button, top right corner, above everything
closeBtn->move(PopupWidth - closeBtn->width() - 6, 6); closeBtn->move(PopupWidth - closeBtn->width() - 6, 6);
closeBtn->raise(); closeBtn->raise();
gamesModel->setUser(currentUser, currentOnline);
adjustSize(); adjustSize();
fetchGames();
} }
// ── Games fetch ─────────────────────────────────────────────────────────────── // ── Games fetch ───────────────────────────────────────────────────────────────
void UserInfoPopup::fetchGames() void UserInfoPopup::fetchGames()
{ {
if (!client || currentUser.isEmpty()) { gamesModel->refresh();
return;
}
Command_GetGamesOfUser cmd;
cmd.set_user_name(currentUser.toStdString());
const QString snapshot = currentUser;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this,
[this, snapshot](const Response &r) { onGamesReceived(r, snapshot); });
client->sendCommand(pend);
} }
void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) void UserInfoPopup::setOnline(bool online)
{ {
if (forUser != currentUser) { if (currentOnline == online) {
return; // stale response, different user showing now
}
gamesModel->clear();
if (r.response_code() != Response::RespOk) {
gamesStatus->setText(tr("Could not load games."));
gamesStatus->show();
gamesView->hide();
return; return;
} }
currentOnline = online;
refreshHeader();
rebuildActionButtons(currentUserInfo, online, currentBuddy, currentIgnored);
gamesModel->setUser(currentUser, online);
}
const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext); void UserInfoPopup::updateGamesDisplay()
if (resp.game_list_size() == 0) { {
gamesStatus->setText(tr("No active games.")); refreshBtn->setEnabled(gamesModel->canRefresh());
gamesStatus->show(); const auto state = gamesModel->getState();
gamesView->hide(); const bool ready = state == UserGamesModel::State::Ready;
return; gamesView->setVisible(ready);
gamesStatus->setVisible(!ready);
switch (state) {
case UserGamesModel::State::Offline:
gamesStatus->setText(tr("User is offline."));
break;
case UserGamesModel::State::Disconnected:
gamesStatus->setText(tr("Not connected to the server."));
break;
case UserGamesModel::State::Loading:
gamesStatus->setText(tr("Loading games…"));
break;
case UserGamesModel::State::Empty:
gamesStatus->setText(tr("No active games."));
break;
case UserGamesModel::State::Error:
gamesStatus->setText(tr("Could not load games."));
break;
case UserGamesModel::State::Ready: {
// Fit exactly to the number of visible rows, scroll when more than 5.
constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint
constexpr int maxRows = 5;
const int count = gamesModel->rowCount();
gamesView->setFixedHeight(qMin(count, maxRows) * rowH + 2);
gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
break;
}
} }
for (int i = 0; i < resp.game_list_size(); ++i) {
auto *item = new QStandardItem;
item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData);
item->setEditable(false);
gamesModel->appendRow(item);
}
gamesStatus->hide();
gamesView->show();
// Fit exactly to the number of visible rows, scroll when more than 5
constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint
constexpr int maxRows = 5;
const int count = gamesModel->rowCount();
const int visible = qMin(count, maxRows);
gamesView->setFixedHeight(visible * rowH + 2);
gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
adjustSize(); adjustSize();
} }
void UserInfoPopup::refreshGames() void UserInfoPopup::refreshGames()
{ {
gamesModel->clear();
gamesView->hide();
gamesStatus->setText(tr("Loading games…"));
gamesStatus->show();
fetchGames(); fetchGames();
} }

View file

@ -1,6 +1,7 @@
#ifndef COCKATRICE_USER_INFO_POPUP_H #ifndef COCKATRICE_USER_INFO_POPUP_H
#define COCKATRICE_USER_INFO_POPUP_H #define COCKATRICE_USER_INFO_POPUP_H
#include "user_games_model.h"
#include "user_list_painter.h" #include "user_list_painter.h"
#include <QColor> #include <QColor>
@ -21,15 +22,13 @@ class QPushButton;
class TabSupervisor; class TabSupervisor;
class QListView; class QListView;
class QPoint; class QPoint;
class QStandardItemModel;
class Response;
template <class Key, class T> class QMap; template <class Key, class T> class QMap;
// ── Roles ───────────────────────────────────────────────────────────────────── // ── Roles ─────────────────────────────────────────────────────────────────────
namespace PopupRoles namespace PopupRoles
{ {
constexpr int GameData = Qt::UserRole + 10; constexpr int GameData = UserGamesModel::GameRole;
} }
// Popup theme // Popup theme
@ -143,6 +142,7 @@ public:
void void
showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
void fetchGames(); void fetchGames();
void setOnline(bool online);
[[nodiscard]] QString getCurrentUser() const [[nodiscard]] QString getCurrentUser() const
{ {
@ -199,7 +199,7 @@ protected:
private slots: private slots:
void refreshGames(); void refreshGames();
void onGamesReceived(const Response &r, const QString &forUser); void updateGamesDisplay();
void onGamesContextMenu(const QPoint &pos); void onGamesContextMenu(const QPoint &pos);
private: private:
@ -218,6 +218,8 @@ private:
QString currentUser; QString currentUser;
ServerInfo_User currentUserInfo; ServerInfo_User currentUserInfo;
bool currentOnline = false; bool currentOnline = false;
bool currentBuddy = false;
bool currentIgnored = false;
std::function<bool(const QString &userName)> gameInviteAvailable; std::function<bool(const QString &userName)> gameInviteAvailable;
UserInfoHeaderWidget *header; UserInfoHeaderWidget *header;
@ -225,7 +227,7 @@ private:
QLabel *gamesLabel; QLabel *gamesLabel;
QFrame *separator; QFrame *separator;
QListView *gamesView; QListView *gamesView;
QStandardItemModel *gamesModel; UserGamesModel *gamesModel;
QLabel *gamesStatus; QLabel *gamesStatus;
QPushButton *closeBtn; QPushButton *closeBtn;
QPushButton *refreshBtn; QPushButton *refreshBtn;

View file

@ -617,7 +617,14 @@ void UserListWidget::bind(UserListManager *mgr)
connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup); connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup);
connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen); connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen);
connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup); connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup);
connect(manager, &UserListManager::userLeftOnline, this, refreshIfPopupOpen); connect(manager, &UserListManager::userLeftOnline, this, [this, refreshIfPopupOpen](const QString &name) {
// The online row may already have been removed; the pinned popup must
// still invalidate its games request and display the offline state.
if (userInfoPopup && userInfoPopup->getCurrentUser() == name) {
userInfoPopup->setOnline(false);
}
refreshIfPopupOpen(name);
});
rebuild(); rebuild();
} }

View file

@ -178,6 +178,18 @@ cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp
cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(
NAME
user_games_model_test
SOURCES
${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/server/user/user_games_model.cpp
user_games_model_test.cpp
LIBS
libcockatrice_network_client_abstract
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/cockatrice/src
${CMAKE_SOURCE_DIR}/libcockatrice_network
)
# The installer's holder probe is Windows-only, but the wording it produces and the # The installer's holder probe is Windows-only, but the wording it produces and the
# question which programs are the payload's own are plain C++ and are what a stuck user # question which programs are the payload's own are plain C++ and are what a stuck user
# has to act on, so they are tested everywhere. # has to act on, so they are tested everywhere.

View file

@ -0,0 +1,248 @@
#include "interface/widgets/server/user/user_games_model.h"
#include <QAbstractItemModelTester>
#include <QCoreApplication>
#include <QPersistentModelIndex>
#include <QSignalSpy>
#include <gtest/gtest.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/server_message.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <memory>
RNG_Abstract *rng = nullptr; // required by the transitive server library; this suite sends no game commands
namespace
{
class GamesClient : public AbstractClient
{
public:
QList<CommandContainer> sent;
GamesClient()
{
setStatus(StatusLoggedIn);
}
~GamesClient() override
{
qDeleteAll(pendingCommands);
pendingCommands.clear();
}
void changeStatus(ClientStatus status)
{
setStatus(status);
}
void answer(int request, Response response)
{
response.set_cmd_id(sent.at(request).cmd_id());
ServerMessage message;
message.set_message_type(ServerMessage::RESPONSE);
*message.mutable_response() = response;
processProtocolItem(message);
}
protected:
void sendCommandContainer(const CommandContainer &command) override
{
sent.append(command);
}
};
Response gamesResponse(std::initializer_list<int> ids)
{
Response response;
response.set_response_code(Response::RespOk);
auto *games = response.MutableExtension(Response_GetGamesOfUser::ext);
for (int id : ids) {
auto *game = games->add_game_list();
game->set_game_id(id);
game->set_room_id(7);
}
return response;
}
using State = UserGamesModel::State;
TEST(UserGamesModel, OfflineUserDoesNotSendARequest)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", false);
model.refresh();
EXPECT_TRUE(client.sent.isEmpty());
EXPECT_EQ(State::Offline, model.getState());
EXPECT_FALSE(model.canRefresh());
EXPECT_EQ(0, model.rowCount());
}
TEST(UserGamesModel, PresenceLossInvalidatesTheOutstandingResponse)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
EXPECT_EQ(State::Loading, model.getState());
EXPECT_FALSE(model.canRefresh());
model.setUser("Alice", false);
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Offline, model.getState());
EXPECT_EQ(0, model.rowCount());
model.setUser("Alice", true);
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
EXPECT_TRUE(model.canRefresh());
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
TEST(UserGamesModel, SwitchingBackToTheSameUserDoesNotAcceptTheirOldRequest)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
model.setUser("Bob", true);
model.setUser("Alice", true);
client.answer(2, gamesResponse({3}));
client.answer(0, gamesResponse({1}));
client.answer(1, gamesResponse({2}));
EXPECT_EQ(1, model.rowCount());
EXPECT_EQ(3, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
TEST(UserGamesModel, RefreshSupersedesThePreviousRequest)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
model.refresh();
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Loading, model.getState());
EXPECT_EQ(0, model.rowCount());
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
TEST(UserGamesModel, EmptySuccessAndMissingPayloadAreDifferentStates)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
client.answer(0, gamesResponse({}));
EXPECT_EQ(State::Empty, model.getState());
EXPECT_TRUE(model.canRefresh());
model.refresh();
Response missingPayload;
missingPayload.set_response_code(Response::RespOk);
client.answer(1, missingPayload);
EXPECT_EQ(State::Error, model.getState());
EXPECT_TRUE(model.canRefresh());
}
TEST(UserGamesModel, FailedRequestsCanBeRetried)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
Response error;
error.set_response_code(Response::RespNameNotFound);
client.answer(0, error);
EXPECT_EQ(State::Error, model.getState());
model.refresh();
client.answer(1, gamesResponse({4}));
EXPECT_EQ(State::Ready, model.getState());
}
TEST(UserGamesModel, DisconnectRejectsLateRepliesAndReconnectFetchesAgain)
{
GamesClient client;
UserGamesModel model(&client);
model.setUser("Alice", true);
client.changeStatus(StatusDisconnecting);
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Disconnected, model.getState());
EXPECT_FALSE(model.canRefresh());
model.refresh();
EXPECT_EQ(1, client.sent.size());
client.changeStatus(StatusLoggedIn);
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
}
TEST(UserGamesModel, ClientDestructionClearsGamesAndDisablesRefresh)
{
auto client = std::make_unique<GamesClient>();
UserGamesModel model(client.get());
model.setUser("Alice", true);
client->answer(0, gamesResponse({1}));
client.reset();
EXPECT_EQ(State::Disconnected, model.getState());
EXPECT_EQ(0, model.rowCount());
EXPECT_FALSE(model.canRefresh());
model.refresh();
EXPECT_EQ(State::Disconnected, model.getState());
}
TEST(UserGamesModel, ResetInvalidatesAnOpenGameActionAndPreservesTheModelContract)
{
GamesClient client;
UserGamesModel model(&client);
QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::Fatal);
model.setUser("Alice", true);
client.answer(0, gamesResponse({1, 2, 3}));
QPersistentModelIndex selected(model.index(1));
EXPECT_TRUE(selected.isValid());
model.refresh();
EXPECT_FALSE(selected.isValid());
EXPECT_EQ(0, model.rowCount());
client.answer(1, gamesResponse({4}));
EXPECT_EQ(1, model.rowCount());
}
TEST(UserGamesModel, StateListenerCanSelectAnotherUserBeforeTheRequestIsSent)
{
GamesClient client;
UserGamesModel model(&client);
bool switched = false;
QObject::connect(&model, &UserGamesModel::stateChanged, &model, [&] {
if (!switched && model.getState() == State::Loading) {
switched = true;
model.setUser("Bob", false);
}
});
model.setUser("Alice", true);
EXPECT_TRUE(client.sent.isEmpty());
EXPECT_EQ(State::Offline, model.getState());
}
TEST(UserGamesModel, TimedOutResponseCannotReplaceARetry)
{
GamesClient client;
UserGamesModel model(&client, 50);
model.setUser("Alice", true);
QSignalSpy changes(&model, &UserGamesModel::stateChanged);
ASSERT_TRUE(changes.wait(1000));
ASSERT_EQ(State::Error, model.getState());
EXPECT_TRUE(model.canRefresh());
model.refresh();
client.answer(0, gamesResponse({1}));
EXPECT_EQ(State::Loading, model.getState());
client.answer(1, gamesResponse({2}));
EXPECT_EQ(State::Ready, model.getState());
EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value<ServerInfo_Game>().game_id());
}
} // namespace
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}