Repository: RetroAchievements/RAIntegration
Pull request #742 (2021-06-02 14:00:59)

discard publish-related changes when server returns publish error


Patch:
diff --git a/src/Exports.cpp b/src/Exports.cpp
index 59e9f2a0..62dc6bbd 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -414,6 +414,14 @@ static void ProcessAchievements()
                 break;
             }
 
+            case ra::services::AchievementRuntime::ChangeType::AchievementPaused:
+            {
+                // hide the challenge indicator - it'll be reshown when unpaused
+                auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
+                pOverlayManager.RemoveChallengeIndicator(pChange.nId);
+                break;
+            }
+
             case ra::services::AchievementRuntime::ChangeType::AchievementActivated:
             {
                 // state change from Waiting, Paused, or Primed to Active.
diff --git a/src/api/impl/ConnectedServer.cpp b/src/api/impl/ConnectedServer.cpp
index aaa2bd57..156942fb 100644
--- a/src/api/impl/ConnectedServer.cpp
+++ b/src/api/impl/ConnectedServer.cpp
@@ -497,7 +497,7 @@ Login::Response ConnectedServer::Login(const Login::Request& request)
 
     rc_api_destroy_request(&api_request);
 
-    return std::move(response);
+    return response;
 }
 
 Logout::Response ConnectedServer::Logout(const Logout::Request&)
@@ -541,7 +541,7 @@ StartSession::Response ConnectedServer::StartSession(const StartSession::Request
 
     rc_api_destroy_request(&api_request);
 
-    return std::move(response);
+    return response;
 }
 
 Ping::Response ConnectedServer::Ping(const Ping::Request& request)
@@ -582,7 +582,7 @@ Ping::Response ConnectedServer::Ping(const Ping::Request& request)
 
     rc_api_destroy_request(&api_request);
 
-    return std::move(response);
+    return response;
 }
 
 FetchUserUnlocks::Response ConnectedServer::FetchUserUnlocks(const FetchUserUnlocks::Request& request)
@@ -622,7 +622,7 @@ FetchUserUnlocks::Response ConnectedServer::FetchUserUnlocks(const FetchUserUnlo
 
     rc_api_destroy_request(&api_request);
 
-    return std::move(response);
+    return response;
 }
 
 AwardAchievement::Response ConnectedServer::AwardAchievement(const AwardAchievement::Request& request)
@@ -664,7 +664,7 @@ AwardAchievement::Response ConnectedServer::AwardAchievement(const AwardAchievem
 
     rc_api_destroy_request(&api_request);
 
-    return std::move(response);
+    return response;
 }
 
 SubmitLeaderboardEntry::Response ConnectedServer::SubmitLeaderboardEntry(const SubmitLeaderboardEntry::Request& request)
@@ -718,7 +718,7 @@ SubmitLeaderboardEntry::Response ConnectedServer::SubmitLeaderboardEntry(const S
 
     rc_api_destroy_request(&api_request);
 
-    return std::move(response);
+    return response;
 }
 
 FetchUserFriends::Response ConnectedServer::FetchUserFriends(const FetchUserFriends::Request&)
@@ -758,7 +758,7 @@ FetchUserFriends::Response ConnectedServer::FetchUserFriends(const FetchUserFrie
         }
     }
 
-    return std::move(response);
+    return response;
 }
 
 ResolveHash::Response ConnectedServer::ResolveHash(const ResolveHash::Request& request)
@@ -795,7 +795,7 @@ ResolveHash::Response ConnectedServer::ResolveHash(const ResolveHash::Request& r
 
     rc_api_destroy_request(&api_request);
 
-    return std::move(response);
+    return response;
 }
 
 FetchGameData::Response ConnectedServer::FetchGameData(const FetchGameData::Request& request)
@@ -848,7 +848,7 @@ FetchGameData::Response ConnectedServer::FetchGameData(const FetchGameData::Requ
 
     rc_api_destroy_request(&api_request);
 
-    return std::move(response);
+    return response;
 }
 
 void ConnectedServer::ProcessGamePatchData(ra::api::FetchGameData::Response &response, const ra::services::Http::Response& httpResponse)
@@ -931,7 +931,7 @@ FetchCodeNotes::Response ConnectedServer::FetchCodeNotes(const FetchCodeNotes::R
         }
     }
 
-    return std::move(response);
+    return response;
 }
 
 void ConnectedServer::ProcessCodeNotes(ra::api::FetchCodeNotes::Response &response, const rapidjson::Value& CodeNotes)
@@ -968,7 +968,7 @@ UpdateCodeNote::Response ConnectedServer::UpdateCodeNote(const UpdateCodeNote::R
         response.Result = ApiResult::Success;
     }
 
-    return std::move(response);
+    return response;
 }
 
 DeleteCodeNote::Response ConnectedServer::DeleteCodeNote(const DeleteCodeNote::Request& request)
@@ -985,7 +985,7 @@ DeleteCodeNote::Response ConnectedServer::DeleteCodeNote(const DeleteCodeNote::R
         response.Result = ApiResult::Success;
     }
 
-    return std::move(response);
+    return response;
 }
 
 UpdateAchievement::Response ConnectedServer::UpdateAchievement(const UpdateAchievement::Request& request)
@@ -1015,7 +1015,7 @@ UpdateAchievement::Response ConnectedServer::UpdateAchievement(const UpdateAchie
         GetRequiredJsonField(response.AchievementId, document, "AchievementID", response);
     }
 
-    return std::move(response);
+    return response;
 }
 
 FetchAchievementInfo::Response ConnectedServer::FetchAchievementInfo(const FetchAchievementInfo::Request& request)
@@ -1076,7 +1076,7 @@ FetchAchievementInfo::Response ConnectedServer::FetchAchievementInfo(const Fetch
         }
     }
 
-    return std::move(response);
+    return response;
 }
 
 FetchLeaderboardInfo::Response ConnectedServer::FetchLeaderboardInfo(const FetchLeaderboardInfo::Request& request)
@@ -1137,7 +1137,7 @@ FetchLeaderboardInfo::Response ConnectedServer::FetchLeaderboardInfo(const Fetch
         }
     }
 
-    return std::move(response);
+    return response;
 }
 
 LatestClient::Response ConnectedServer::LatestClient(const LatestClient::Request& request)
diff --git a/src/api/impl/OfflineServer.cpp b/src/api/impl/OfflineServer.cpp
index d28d3f4b..8c651f49 100644
--- a/src/api/impl/OfflineServer.cpp
+++ b/src/api/impl/OfflineServer.cpp
@@ -17,14 +17,14 @@ Login::Response OfflineServer::Login(const Login::Request& request)
     response.Result = ApiResult::Success;
     response.Username = request.Username;
     response.ApiToken = "offlineToken";
-    return std::move(response);
+    return response;
 }
 
 Logout::Response OfflineServer::Logout(const Logout::Request&) noexcept
 {
     Logout::Response response;
     response.Result = ApiResult::Success;
-    return std::move(response);
+    return response;
 }
 
 FetchGameData::Response OfflineServer::FetchGameData(const FetchGameData::Request& request)
@@ -57,7 +57,7 @@ FetchGameData::Response OfflineServer::FetchGameData(const FetchGameData::Reques
         ConnectedServer::ProcessGamePatchData(response, httpResponse);
     }
 
-    return std::move(response);
+    return response;
 }
 
 FetchCodeNotes::Response OfflineServer::FetchCodeNotes(const FetchCodeNotes::Request& request)
@@ -85,7 +85,7 @@ FetchCodeNotes::Response OfflineServer::FetchCodeNotes(const FetchCodeNotes::Req
         ConnectedServer::ProcessCodeNotes(response, document);
     }
 
-    return std::move(response);
+    return response;
 }
 
 } // namespace impl
diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp
index d6281894..5e557097 100644
--- a/src/services/AchievementRuntime.cpp
+++ b/src/services/AchievementRuntime.cpp
@@ -232,6 +232,9 @@ static void map_event_to_change(const rc_runtime_event_t* pRuntimeEvent)
         case RC_RUNTIME_EVENT_ACHIEVEMENT_RESET:
             nChangeType = AchievementRuntime::ChangeType::AchievementReset;
             break;
+        case RC_RUNTIME_EVENT_ACHIEVEMENT_PAUSED:
+            nChangeType = AchievementRuntime::ChangeType::AchievementPaused;
+            break;
         case RC_RUNTIME_EVENT_ACHIEVEMENT_DISABLED:
             nChangeType = AchievementRuntime::ChangeType::AchievementDisabled;
             break;
diff --git a/src/services/AchievementRuntime.hh b/src/services/AchievementRuntime.hh
index a01f4228..19aaa6e8 100644
--- a/src/services/AchievementRuntime.hh
+++ b/src/services/AchievementRuntime.hh
@@ -115,7 +115,16 @@ public:
         if (!m_bInitialized)
             return false;
 
-        return (m_pRuntime.richpresence != nullptr && m_pRuntime.richpresence->richpresence != nullptr);
+        /* valid rich presence */
+        if (m_pRuntime.richpresence != nullptr && m_pRuntime.richpresence->richpresence != nullptr)
+            return true;
+
+        /* invalid rich presence */
+        if (m_nRichPresenceParseResult != RC_OK)
+            return true;
+
+        /* no rich presence */
+        return false;
     }
 
     /// <summary>
@@ -129,6 +138,7 @@ public:
         None = 0,
         AchievementTriggered,
         AchievementReset,
+        AchievementPaused,
         LeaderboardStarted,
         LeaderboardCanceled,
         LeaderboardUpdated,
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index 3587dbde..36d6db08 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -1076,6 +1076,16 @@ void AssetListViewModel::Publish(std::vector<ra::data::models::AssetModelBase*>&
     }
 
     vmAssetUpload.ShowModal(*this);
+
+    // if anything failed, revert to pre-publish state
+    // this assumes that publish can only be called on committed assets
+    for (auto* pAsset : vAssets)
+    {
+        Expects(pAsset != nullptr);
+        if (pAsset->GetChanges() != ra::data::models::AssetChanges::None)
+            pAsset->RestoreLocalCheckpoint();
+    }
+
     vmAssetUpload.ShowResults();
 }
 
diff --git a/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp b/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
index 9276dd54..01030de0 100644
--- a/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
+++ b/src/ui/viewmodels/OverlayAchievementsPageViewModel.cpp
@@ -129,7 +129,7 @@ void OverlayAchievementsPageViewModel::Refresh()
     // achievement list
     std::vector<const ra::data::models::AchievementModel*> vRecentAchievements;
     std::vector<const ra::data::models::AchievementModel*> vActiveChallengeAchievements;
-    std::vector<const ra::data::models::AchievementModel*> vAlmostThereAchievements;
+    std::vector<std::pair<const ra::data::models::AchievementModel*, unsigned>> vAlmostThereAchievements;
     std::vector<const ra::data::models::AchievementModel*> vUnofficialAchievements;
     std::vector<const ra::data::models::AchievementModel*> vLocalAchievements;
     std::vector<const ra::data::models::AchievementModel*> vLockedCoreAchievements;
@@ -180,7 +180,15 @@ void OverlayAchievementsPageViewModel::Refresh()
                     const auto nProgress = pTrigger->measured_value * 100 / pTrigger->measured_target;
                     if (nProgress >= 80)
                     {
-                        vAlmostThereAchievements.push_back(vmAchievement);
+                        auto pIter = vAlmostThereAchievements.begin();
+                        while (pIter < vAlmostThereAchievements.end())
+                        {
+                            if (pIter->second < nProgress)
+                                break;
+
+                            ++pIter;
+                        }
+                        vAlmostThereAchievements.insert(pIter, std::make_pair(vmAchievement, nProgress));
                         continue;
                     }
                 }
@@ -269,8 +277,9 @@ void OverlayAchievementsPageViewModel::Refresh()
         auto& pvmHeader = GetNextItem(&nIndex);
         SetHeader(pvmHeader, L"Almost There");
 
-        for (const auto* vmAchievement : vAlmostThereAchievements)
+        for (const auto& pPair : vAlmostThereAchievements)
         {
+            const auto* vmAchievement = pPair.first;
             auto& pvmAchievement = GetNextItem(&nIndex);
             SetAchievement(pvmAchievement, *vmAchievement);
             nMaxPts += vmAchievement->GetPoints();


Test patch:
diff --git a/tests/services/AchievementRuntime_Tests.cpp b/tests/services/AchievementRuntime_Tests.cpp
index 3d24ebd4..01a988a9 100644
--- a/tests/services/AchievementRuntime_Tests.cpp
+++ b/tests/services/AchievementRuntime_Tests.cpp
@@ -881,9 +881,11 @@ public:
 
         AchievementRuntimeHarness runtime;
         runtime.mockEmulatorContext.MockMemory(memory);
+        Assert::IsFalse(runtime.HasRichPresence());
 
         const char* pRichPresence = "Format:Num\nFormatType:Value\n\nDisplay:\n@Num(0xH01) @Num(d0xH01)\n";
         runtime.ActivateRichPresence(pRichPresence);
+        Assert::IsTrue(runtime.HasRichPresence());
 
         // string is evaluated with current memrefs (which will be 0)
         Assert::AreEqual(std::wstring(L"0 0"), runtime.GetRichPresenceDisplayString());
@@ -918,15 +920,19 @@ public:
 
         AchievementRuntimeHarness runtime;
         runtime.mockEmulatorContext.MockMemory(memory);
+        Assert::IsFalse(runtime.HasRichPresence());
 
         runtime.ActivateRichPresence("Display:\nHello, World\n");
         Assert::AreEqual(std::wstring(L"Hello, World"), runtime.GetRichPresenceDisplayString());
+        Assert::IsTrue(runtime.HasRichPresence());
 
         runtime.ActivateRichPresence("Display:\nNew String\n");
         Assert::AreEqual(std::wstring(L"New String"), runtime.GetRichPresenceDisplayString());
+        Assert::IsTrue(runtime.HasRichPresence());
 
         runtime.ActivateRichPresence("");
         Assert::AreEqual(std::wstring(L"No Rich Presence defined."), runtime.GetRichPresenceDisplayString());
+        Assert::IsFalse(runtime.HasRichPresence());
     }
 
     TEST_METHOD(TestActivateRichPresenceWithError)
@@ -939,6 +945,7 @@ public:
 
         const char* pRichPresence = "Format:Num\nFormatType:Value\n\nDisplay:\n@Num(0H01) @Num(d0xH01)\n";
         runtime.ActivateRichPresence(pRichPresence);
+        Assert::IsTrue(runtime.HasRichPresence());
 
         Assert::AreEqual(std::wstring(L"Parse error -6 (line 5): Invalid operator"), runtime.GetRichPresenceDisplayString());
     }
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index 60a8ab23..53baab04 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -394,31 +394,39 @@ private:
             for (auto* pAsset : vAssets)
             {
                 Expects(pAsset != nullptr);
-                pAsset->UpdateServerCheckpoint();
+                if (m_mPublishServerErrors.find(pAsset->GetID()) == m_mPublishServerErrors.end())
+                    pAsset->UpdateServerCheckpoint();
+                else
+                    pAsset->RestoreLocalCheckpoint();
             }
         }
 
         std::vector<ra::data::models::AssetModelBase*> PublishedAssets;
 
-        void SetPublishError(ra::AchievementID nId, const std::wstring& sError)
+        void SetValidationError(ra::AchievementID nId, const std::wstring& sError)
         {
-            m_mPublishErrors.insert_or_assign(nId, sError);
+            m_mValidationErrors.insert_or_assign(nId, sError);
         }
 
         void ValidateAchievementForCore(std::wstring& sError, const ra::data::models::AchievementModel& pAchievement) const override
         {
-            const auto pIter = m_mPublishErrors.find(pAchievement.GetID());
-            if (pIter != m_mPublishErrors.end())
+            const auto pIter = m_mValidationErrors.find(pAchievement.GetID());
+            if (pIter != m_mValidationErrors.end())
                 sError.append(ra::StringPrintf(L"\n* %s: %s", pAchievement.GetName(), pIter->second));
         }
 
+        void SetPublishServerError(ra::AchievementID nId, const std::string& sError)
+        {
+            m_mPublishServerErrors.insert_or_assign(nId, sError);
+        }
+
         bool SelectionContainsInvalidAsset(const std::vector<ra::data::models::AssetModelBase*>& vSelectedAssets, _Out_ std::wstring& sErrorMessage) const override
         {
             for (const auto* pAsset : vSelectedAssets)
             {
                 Expects(pAsset != nullptr);
-                const auto pIter = m_mPublishErrors.find(pAsset->GetID());
-                if (pIter != m_mPublishErrors.end())
+                const auto pIter = m_mValidationErrors.find(pAsset->GetID());
+                if (pIter != m_mValidationErrors.end())
                 {
                     sErrorMessage = pIter->second;
                     return true;
@@ -441,7 +449,8 @@ private:
 
 
     private:
-        std::map<ra::AchievementID, std::wstring> m_mPublishErrors;
+        std::map<ra::AchievementID, std::wstring> m_mValidationErrors;
+        std::map<ra::AchievementID, std::string> m_mPublishServerErrors;
     };
 
 public:
@@ -1581,7 +1590,7 @@ public:
 
         Assert::AreEqual({ 2U }, vmAssetList.FilteredAssets().Count());
         vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
-        vmAssetList.SetPublishError(vmAssetList.FilteredAssets().GetItemAt(1)->GetId(), L"Error message goes here.");
+        vmAssetList.SetValidationError(vmAssetList.FilteredAssets().GetItemAt(1)->GetId(), L"Error message goes here.");
         vmAssetList.ForceUpdateButtons();
 
         bool bMessageSeen = false;
@@ -1865,7 +1874,7 @@ public:
         Expects(pItem != nullptr);
         pItem->SetName(L"Test1b");
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
-        vmAssetList.SetPublishError(pItem->GetID(), L"Error message goes here.");
+        vmAssetList.SetValidationError(pItem->GetID(), L"Error message goes here.");
         vmAssetList.ForceUpdateButtons();
 
         bool bMessageSeen = false;
@@ -1960,7 +1969,7 @@ public:
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
-        vmAssetList.SetPublishError(1U, L"NewFeature is pre-release functionality");
+        vmAssetList.SetValidationError(1U, L"NewFeature is pre-release functionality");
 
         bool bDialogSeen = false;
         vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
@@ -2183,13 +2192,13 @@ public:
         vmAssetList.AssertButtonState(SaveButtonState::SaveAllDisabled);
     }
 
-    TEST_METHOD(TestSaveSelectedPromoteToCoreAbort)
+    TEST_METHOD(TestSaveSelectedPromoteToCoreValidationError)
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddAchievement(AssetCategory::Unofficial, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
         vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
-        vmAssetList.SetPublishError(1U, L"Test excuse");
+        vmAssetList.SetValidationError(1U, L"Test excuse");
 
         bool bDialogSeen = false;
         vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
@@ -2216,6 +2225,40 @@ public:
         Assert::AreEqual(AssetCategory::Unofficial, pItem->GetCategory());
     }
 
+    TEST_METHOD(TestSaveSelectedPromoteToCoreServerError)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.MockGameId(22U);
+        vmAssetList.AddAchievement(AssetCategory::Unofficial, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
+        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Unofficial);
+        vmAssetList.SetPublishServerError(1U, "Not allowed.");
+
+        bool bDialogSeen = false;
+        vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogSeen = true;
+            Assert::AreEqual(std::wstring(L"Are you sure you want to promote 1 items to core?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Items in core are officially available for players to earn."), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+            return DialogResult::Yes;
+        });
+
+        const auto* pItem = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(0));
+        Expects(pItem != nullptr);
+        Assert::AreEqual(AssetChanges::None, pItem->GetChanges());
+
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::Promote);
+        vmAssetList.SaveSelected();
+
+        Assert::IsTrue(bDialogSeen);
+
+        Assert::AreEqual({ 1U }, vmAssetList.PublishedAssets.size());      // tried to publish
+        Assert::AreEqual(AssetChanges::None, pItem->GetChanges());
+        Assert::AreEqual(AssetCategory::Unofficial, pItem->GetCategory()); // didn't change category
+    }
+
     TEST_METHOD(TestSaveSelectedDemoteToUnofficial)
     {
         AssetListViewModelHarness vmAssetList;
@@ -2253,6 +2296,40 @@ public:
         vmAssetList.AssertButtonState(SaveButtonState::SaveAllDisabled);
     }
 
+    TEST_METHOD(TestSaveSelectedDemoteToUnofficialServerError)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.MockGameId(22U);
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
+        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetPublishServerError(1U, "Not allowed.");
+
+        bool bDialogSeen = false;
+        vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogSeen = true;
+            Assert::AreEqual(std::wstring(L"Are you sure you want to demote 1 items to unofficial?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Items in unofficial can no longer be earned by players."), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+            return DialogResult::Yes;
+        });
+
+        const auto* pItem = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(0));
+        Expects(pItem != nullptr);
+        Assert::AreEqual(AssetChanges::None, pItem->GetChanges());
+
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::Demote);
+        vmAssetList.SaveSelected();
+
+        Assert::IsTrue(bDialogSeen);
+
+        Assert::AreEqual({ 1U }, vmAssetList.PublishedAssets.size());      // tried to publish
+        Assert::AreEqual(AssetChanges::None, pItem->GetChanges());
+        Assert::AreEqual(AssetCategory::Core, pItem->GetCategory());       // didn't change category
+    }
+
     TEST_METHOD(TestSaveSelectedDemoteAllToUnofficial)
     {
         AssetListViewModelHarness vmAssetList;
@@ -2632,7 +2709,7 @@ public:
         Assert::AreEqual(AssetListViewModel::FilterCategory::Core, vmAssetList.GetFilterCategory());
 
         vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
-        vmAssetList.SetPublishError(vmAssetList.FilteredAssets().GetItemAt(1)->GetId(), L"Error message goes here.");
+        vmAssetList.SetValidationError(vmAssetList.FilteredAssets().GetItemAt(1)->GetId(), L"Error message goes here.");
         vmAssetList.ForceUpdateButtons();
 
         bool bMessageSeen = false;
@@ -3061,7 +3138,7 @@ public:
         vmAssetList.MockGameId(22U);
         vmAssetList.AddThreeAchievements();
         vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
-        vmAssetList.SetPublishError(vmAssetList.FilteredAssets().GetItemAt(0)->GetId(), L"Error message goes here.");
+        vmAssetList.SetValidationError(vmAssetList.FilteredAssets().GetItemAt(0)->GetId(), L"Error message goes here.");
         vmAssetList.ForceUpdateButtons();
         vmAssetList.AssertButtonState(ResetButtonState::Reset);
 
diff --git a/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp b/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
index fb16be3e..2dc1a11f 100644
--- a/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayAchievementsPageViewModel_Tests.cpp
@@ -624,6 +624,67 @@ public:
         Assert::IsNull(achievementsPage.GetItem(7));
     }
 
+    TEST_METHOD(TestRefreshAlmostThereAchievementsSorting)
+    {
+        OverlayAchievementsPageViewModelHarness achievementsPage;
+        auto& pAch1 = achievementsPage.NewAchievement(AssetCategory::Core);
+        pAch1.SetID(1);
+        pAch1.SetPoints(1U);
+        pAch1.SetState(AssetState::Active);
+        achievementsPage.SetProgress(1U, 85, 100);
+        auto& pAch2 = achievementsPage.NewAchievement(AssetCategory::Core);
+        pAch2.SetID(2);
+        pAch2.SetPoints(2U);
+        pAch2.SetState(AssetState::Active);
+        achievementsPage.SetProgress(2U, 95, 100);
+        auto& pAch3 = achievementsPage.NewAchievement(AssetCategory::Core);
+        pAch3.SetID(3);
+        pAch3.SetPoints(3U);
+        pAch3.SetState(AssetState::Active);
+        achievementsPage.SetProgress(3U, 85, 100);
+        auto& pAch4 = achievementsPage.NewAchievement(AssetCategory::Core);
+        pAch4.SetID(4);
+        pAch4.SetPoints(4U);
+        pAch4.SetState(AssetState::Active);
+        achievementsPage.SetProgress(4U, 90, 100);
+
+        achievementsPage.Refresh();
+        Assert::AreEqual(std::wstring(L"Achievements"), achievementsPage.GetTitle());
+        Assert::AreEqual(std::wstring(L"0 of 4 won (0/10)"), achievementsPage.GetSummary());
+
+        auto const* pItem = achievementsPage.GetItem(0);
+        Expects(pItem != nullptr);
+        Assert::IsTrue(pItem->IsHeader());
+        Assert::AreEqual(std::wstring(L"Almost There"), pItem->GetLabel());
+        Assert::IsFalse(pItem->IsDisabled());
+
+        pItem = achievementsPage.GetItem(1);
+        Expects(pItem != nullptr);
+        Assert::AreEqual(2, pItem->GetId());
+        Assert::AreEqual(100U, pItem->GetProgressMaximum());
+        Assert::AreEqual(95U, pItem->GetProgressValue());
+
+        pItem = achievementsPage.GetItem(2);
+        Expects(pItem != nullptr);
+        Assert::AreEqual(4, pItem->GetId());
+        Assert::AreEqual(100U, pItem->GetProgressMaximum());
+        Assert::AreEqual(90U, pItem->GetProgressValue());
+
+        pItem = achievementsPage.GetItem(3);
+        Expects(pItem != nullptr);
+        Assert::AreEqual(1, pItem->GetId());
+        Assert::AreEqual(100U, pItem->GetProgressMaximum());
+        Assert::AreEqual(85U, pItem->GetProgressValue());
+
+        pItem = achievementsPage.GetItem(4);
+        Expects(pItem != nullptr);
+        Assert::AreEqual(3, pItem->GetId());
+        Assert::AreEqual(100U, pItem->GetProgressMaximum());
+        Assert::AreEqual(85U, pItem->GetProgressValue());
+
+        Assert::IsNull(achievementsPage.GetItem(5));
+    }
+
     TEST_METHOD(TestRefreshSession)
     {
         OverlayAchievementsPageViewModelHarness achievementsPage;