Repository: RetroAchievements/RAIntegration
Pull request #611 (2020-09-29 00:52:32)

add AssetEditorViewModel


Patch:
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index 4a0e1d08..a01b991d 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -127,6 +127,7 @@
     <ClCompile Include="ui\ViewModelBase.cpp" />
     <ClCompile Include="ui\ViewModelCollection.cpp" />
     <ClCompile Include="ui\viewmodels\AchievementViewModel.cpp" />
+    <ClCompile Include="ui\viewmodels\AssetEditorViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\AssetListViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\AssetViewModelBase.cpp" />
     <ClCompile Include="ui\viewmodels\BrokenAchievementsViewModel.cpp" />
@@ -290,6 +291,7 @@
     <ClInclude Include="ui\ViewModelBase.hh" />
     <ClInclude Include="ui\ViewModelCollection.hh" />
     <ClInclude Include="ui\viewmodels\AchievementViewModel.hh" />
+    <ClInclude Include="ui\viewmodels\AssetEditorViewModel.hh" />
     <ClInclude Include="ui\viewmodels\AssetListViewModel.hh" />
     <ClInclude Include="ui\viewmodels\AssetViewModelBase.hh" />
     <ClInclude Include="ui\viewmodels\BrokenAchievementsViewModel.hh" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index 6afab531..7c5a18fe 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -339,6 +339,9 @@
     <ClCompile Include="ui\win32\AssetListDialog.cpp">
       <Filter>UI\Win32</Filter>
     </ClCompile>
+    <ClCompile Include="ui\viewmodels\AssetEditorViewModel.cpp">
+      <Filter>UI\ViewModels</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="RA_Achievement.h">
@@ -857,6 +860,9 @@
     <ClInclude Include="ui\win32\AssetListDialog.hh">
       <Filter>UI\Win32</Filter>
     </ClInclude>
+    <ClInclude Include="ui\viewmodels\AssetEditorViewModel.hh">
+      <Filter>UI\ViewModels</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="RA_Shared.rc">
diff --git a/src/ui/viewmodels/AchievementViewModel.cpp b/src/ui/viewmodels/AchievementViewModel.cpp
index 0569587d..4df23032 100644
--- a/src/ui/viewmodels/AchievementViewModel.cpp
+++ b/src/ui/viewmodels/AchievementViewModel.cpp
@@ -1,7 +1,5 @@
 #include "AchievementViewModel.hh"
 
-#include "ui\viewmodels\MessageBoxViewModel.hh"
-
 namespace ra {
 namespace ui {
 namespace viewmodels {
@@ -22,11 +20,6 @@ AchievementViewModel::AchievementViewModel() noexcept
     GSL_SUPPRESS_F6 AddAssetDefinition(m_pTrigger, TriggerProperty);
 }
 
-void AchievementViewModel::SelectBadgeFile()
-{
-    ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Not implemented");
-}
-
 void AchievementViewModel::Activate()
 {
     if (IsActive())
diff --git a/src/ui/viewmodels/AchievementViewModel.hh b/src/ui/viewmodels/AchievementViewModel.hh
index ebba177b..3ffdfb1d 100644
--- a/src/ui/viewmodels/AchievementViewModel.hh
+++ b/src/ui/viewmodels/AchievementViewModel.hh
@@ -43,11 +43,6 @@ public:
     /// </summary>
     void SetBadge(const std::wstring& sValue) { SetValue(BadgeProperty, sValue); }
 
-    /// <summary>
-    /// Opens the file chooser to select a new badge image.
-    /// </summary>
-    void SelectBadgeFile();
-
     /// <summary>
     /// The <see cref="ModelProperty" /> for whether or not the emulator should be paused when the trigger is reset.
     /// </summary>
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
new file mode 100644
index 00000000..ffc60b24
--- /dev/null
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -0,0 +1,183 @@
+#include "AssetEditorViewModel.hh"
+
+#include "ui\viewmodels\MessageBoxViewModel.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+const IntModelProperty AssetEditorViewModel::IDProperty("AssetEditorViewModel", "ID", 0);
+const StringModelProperty AssetEditorViewModel::NameProperty("AssetEditorViewModel", "Name", L"[No Asset Loaded]");
+const StringModelProperty AssetEditorViewModel::DescriptionProperty("AssetEditorViewModel", "Description", L"Open an asset from the Assets List");
+const IntModelProperty AssetEditorViewModel::CategoryProperty("AssetEditorViewModel", "Category", ra::etoi(AssetCategory::Core));
+const IntModelProperty AssetEditorViewModel::StateProperty("AssetEditorViewModel", "State", ra::etoi(AssetState::Inactive));
+const IntModelProperty AssetEditorViewModel::PointsProperty("AssetEditorViewModel", "Points", 0);
+const StringModelProperty AssetEditorViewModel::BadgeProperty("AssetEditorViewModel", "Badge", L"00000");
+const BoolModelProperty AssetEditorViewModel::PauseOnResetProperty("AssetEditorViewModel", "PauseOnReset", false);
+const BoolModelProperty AssetEditorViewModel::PauseOnTriggerProperty("AssetEditorViewModel", "PauseOnTrigger", false);
+const BoolModelProperty AssetEditorViewModel::AssetLoadedProperty("AssetEditorViewModel", "AssetLoaded", false);
+
+AssetEditorViewModel::AssetEditorViewModel() noexcept
+{
+    SetWindowTitle(L"Asset Editor");
+}
+
+AssetEditorViewModel::~AssetEditorViewModel()
+{
+    if (m_pAsset)
+    {
+        m_pAsset->RemoveNotifyTarget(*this);
+        m_pAsset = nullptr;
+    }
+}
+
+void AssetEditorViewModel::SelectBadgeFile()
+{
+    ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Not implemented");
+}
+
+void AssetEditorViewModel::LoadAsset(AssetViewModelBase* pAsset)
+{
+    if (m_pAsset == pAsset)
+        return;
+
+    if (m_pAsset)
+        m_pAsset->RemoveNotifyTarget(*this);
+
+    m_pAsset = pAsset;
+    if (pAsset)
+    {
+        pAsset->AddNotifyTarget(*this);
+
+        SetName(pAsset->GetName());
+        SetDescription(pAsset->GetDescription());
+        SetState(pAsset->GetState());
+        SetCategory(pAsset->GetCategory());
+
+        if (pAsset->GetCategory() == AssetCategory::Local)
+            SetValue(IDProperty, 0);
+        else
+            SetValue(IDProperty, pAsset->GetID());
+
+        const auto* pAchievement = dynamic_cast<AchievementViewModel*>(pAsset);
+        if (pAchievement != nullptr)
+        {
+            SetPoints(pAchievement->GetPoints());
+            SetBadge(pAchievement->GetBadge());
+            SetPauseOnReset(pAchievement->IsPauseOnReset());
+            SetPauseOnTrigger(pAchievement->IsPauseOnTrigger());
+            SetWindowTitle(L"Achievement Editor");
+        }
+
+        SetValue(AssetLoadedProperty, true);
+    }
+    else
+    {
+        SetValue(AssetLoadedProperty, false);
+
+        SetName(NameProperty.GetDefaultValue());
+        SetValue(IDProperty, IDProperty.GetDefaultValue());
+        SetState(ra::itoe<AssetState>(StateProperty.GetDefaultValue()));
+        SetDescription(DescriptionProperty.GetDefaultValue());
+        SetCategory(ra::itoe<AssetCategory>(CategoryProperty.GetDefaultValue()));
+        SetPoints(PointsProperty.GetDefaultValue());
+        SetBadge(BadgeProperty.GetDefaultValue());
+        SetPauseOnReset(PauseOnResetProperty.GetDefaultValue());
+        SetPauseOnTrigger(PauseOnTriggerProperty.GetDefaultValue());
+        SetWindowTitle(L"Asset Editor");
+    }
+}
+
+void AssetEditorViewModel::OnViewModelBoolValueChanged(const BoolModelProperty::ChangeArgs& args)
+{
+    if (args.Property == AchievementViewModel::PauseOnResetProperty)
+        SetPauseOnReset(args.tNewValue);
+    else if (args.Property == AchievementViewModel::PauseOnTriggerProperty)
+        SetPauseOnTrigger(args.tNewValue);
+}
+
+void AssetEditorViewModel::OnViewModelStringValueChanged(const StringModelProperty::ChangeArgs& args)
+{
+    if (args.Property == AssetViewModelBase::DescriptionProperty)
+        SetDescription(args.tNewValue);
+    else if (args.Property == AssetViewModelBase::NameProperty)
+        SetName(args.tNewValue);
+    else if (args.Property == AchievementViewModel::BadgeProperty)
+        SetBadge(args.tNewValue);
+}
+
+void AssetEditorViewModel::OnViewModelIntValueChanged(const IntModelProperty::ChangeArgs& args)
+{
+    if (args.Property == AssetViewModelBase::StateProperty)
+        SetState(ra::itoe<AssetState>(args.tNewValue));
+    else if (args.Property == AssetViewModelBase::CategoryProperty)
+        SetCategory(ra::itoe<AssetCategory>(args.tNewValue));
+    else if (args.Property == AchievementViewModel::PointsProperty)
+        SetPoints(args.tNewValue);
+    else if (args.Property == AssetViewModelBase::IDProperty)
+        SetValue(IDProperty, args.tNewValue);
+}
+
+void AssetEditorViewModel::OnValueChanged(const BoolModelProperty::ChangeArgs& args)
+{
+    auto* pAchievement = dynamic_cast<AchievementViewModel*>(m_pAsset);
+    if (pAchievement != nullptr)
+    {
+        if (args.Property == PauseOnResetProperty)
+            pAchievement->SetPauseOnReset(args.tNewValue);
+        else if (args.Property == PauseOnTriggerProperty)
+            pAchievement->SetPauseOnTrigger(args.tNewValue);
+    }
+}
+
+void AssetEditorViewModel::OnValueChanged(const StringModelProperty::ChangeArgs& args)
+{
+    if (m_pAsset != nullptr)
+    {
+        if (args.Property == DescriptionProperty)
+        {
+            m_pAsset->SetDescription(args.tNewValue);
+        }
+        else if (args.Property == NameProperty)
+        {
+            m_pAsset->SetName(args.tNewValue);
+        }
+        else
+        {
+            auto* pAchievement = dynamic_cast<AchievementViewModel*>(m_pAsset);
+            if (pAchievement != nullptr)
+            {
+                if (args.Property == BadgeProperty)
+                    pAchievement->SetBadge(args.tNewValue);
+            }
+        }
+    }
+}
+
+void AssetEditorViewModel::OnValueChanged(const IntModelProperty::ChangeArgs& args)
+{
+    if (m_pAsset != nullptr)
+    {
+        if (args.Property == StateProperty)
+        {
+            m_pAsset->SetState(ra::itoe<AssetState>(args.tNewValue));
+        }
+        else if (args.Property == CategoryProperty)
+        {
+            m_pAsset->SetCategory(ra::itoe<AssetCategory>(args.tNewValue));
+        }
+        else
+        {
+            auto* pAchievement = dynamic_cast<AchievementViewModel*>(m_pAsset);
+            if (pAchievement != nullptr)
+            {
+                if (args.Property == PointsProperty)
+                    pAchievement->SetPoints(args.tNewValue);
+            }
+        }
+    }
+}
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
diff --git a/src/ui/viewmodels/AssetEditorViewModel.hh b/src/ui/viewmodels/AssetEditorViewModel.hh
new file mode 100644
index 00000000..c831b1dd
--- /dev/null
+++ b/src/ui/viewmodels/AssetEditorViewModel.hh
@@ -0,0 +1,200 @@
+#ifndef RA_UI_ASSETEDITOR_VIEW_MODEL_H
+#define RA_UI_ASSETEDITOR_VIEW_MODEL_H
+#pragma once
+
+#include "ui\WindowViewModelBase.hh"
+
+#include "AssetViewModelBase.hh"
+
+#include "AchievementViewModel.hh"
+
+#include "data\Types.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+class AssetEditorViewModel : public WindowViewModelBase, protected ViewModelBase::NotifyTarget
+{
+public:
+    GSL_SUPPRESS_F6 AssetEditorViewModel() noexcept;
+    ~AssetEditorViewModel();
+
+    AssetEditorViewModel(const AssetEditorViewModel&) noexcept = delete;
+    AssetEditorViewModel& operator=(const AssetEditorViewModel&) noexcept = delete;
+    AssetEditorViewModel(AssetEditorViewModel&&) noexcept = delete;
+    AssetEditorViewModel& operator=(AssetEditorViewModel&&) noexcept = delete;
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not the emulator should be paused when the achievement triggers.
+    /// </summary>
+    static const BoolModelProperty AssetLoadedProperty;
+
+    /// <summary>
+    /// Gets whether or not the emulator should be paused when the achievement triggers.
+    /// </summary>
+    bool IsAssetLoaded() const { return GetValue(AssetLoadedProperty); }
+
+    // ===== Common =====
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the asset id.
+    /// </summary>
+    static const IntModelProperty IDProperty;
+
+    /// <summary>
+    /// Gets the asset id.
+    /// </summary>
+    uint32_t GetID() const { return ra::to_unsigned(GetValue(IDProperty)); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the name.
+    /// </summary>
+    static const StringModelProperty NameProperty;
+
+    /// <summary>
+    /// Gets the name to display.
+    /// </summary>
+    const std::wstring& GetName() const { return GetValue(NameProperty); }
+
+    /// <summary>
+    /// Sets the name to display.
+    /// </summary>
+    void SetName(const std::wstring& sValue) { SetValue(NameProperty, sValue); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the description of the asset.
+    /// </summary>
+    static const StringModelProperty DescriptionProperty;
+
+    /// <summary>
+    /// Gets the description of the asset.
+    /// </summary>
+    const std::wstring& GetDescription() const { return GetValue(DescriptionProperty); }
+
+    /// <summary>
+    /// Sets the description of the asset.
+    /// </summary>
+    void SetDescription(const std::wstring& sValue) { SetValue(DescriptionProperty, sValue); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the asset category.
+    /// </summary>
+    static const IntModelProperty CategoryProperty;
+
+    /// <summary>
+    /// Gets the asset category.
+    /// </summary>
+    AssetCategory GetCategory() const { return ra::itoe<AssetCategory>(GetValue(CategoryProperty)); }
+
+    /// <summary>
+    /// Sets the asset category.
+    /// </summary>
+    void SetCategory(AssetCategory nValue) { SetValue(CategoryProperty, ra::etoi(nValue)); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the asset state.
+    /// </summary>
+    static const IntModelProperty StateProperty;
+
+    /// <summary>
+    /// Gets the asset state.
+    /// </summary>
+    AssetState GetState() const { return ra::itoe<AssetState>(GetValue(StateProperty)); }
+
+    /// <summary>
+    /// Sets the asset state.
+    /// </summary>
+    void SetState(AssetState nValue) { SetValue(StateProperty, ra::etoi(nValue)); }
+
+    // ===== Achievement Specific =====
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the achievement points.
+    /// </summary>
+    static const IntModelProperty PointsProperty;
+
+    /// <summary>
+    /// Gets the points for the achievement.
+    /// </summary>
+    int GetPoints() const { return GetValue(PointsProperty); }
+
+    /// <summary>
+    /// Sets the points for the achievement.
+    /// </summary>
+    void SetPoints(int nValue) { SetValue(PointsProperty, nValue); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the badge.
+    /// </summary>
+    static const StringModelProperty BadgeProperty;
+
+    /// <summary>
+    /// Gets the name of the badge associated to the achievement.
+    /// </summary>
+    const std::wstring& GetBadge() const { return GetValue(BadgeProperty); }
+
+    /// <summary>
+    /// Sets the name of the badge associated to the achievement.
+    /// </summary>
+    void SetBadge(const std::wstring& sValue) { SetValue(BadgeProperty, sValue); }
+
+    /// <summary>
+    /// Opens the file chooser to select a new badge image.
+    /// </summary>
+    void SelectBadgeFile();
+
+    // ===== Trigger =====
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not the emulator should be paused when the trigger is reset.
+    /// </summary>
+    static const BoolModelProperty PauseOnResetProperty;
+
+    /// <summary>
+    /// Gets whether or not the emulator should be paused when the trigger is reset.
+    /// </summary>
+    bool IsPauseOnReset() const { return GetValue(PauseOnResetProperty); }
+
+    /// <summary>
+    /// Sets whether or not the emulator should be paused when the trigger is reset.
+    /// </summary>
+    void SetPauseOnReset(bool nValue) { SetValue(PauseOnResetProperty, nValue); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not the emulator should be paused when the achievement triggers.
+    /// </summary>
+    static const BoolModelProperty PauseOnTriggerProperty;
+
+    /// <summary>
+    /// Gets whether or not the emulator should be paused when the achievement triggers.
+    /// </summary>
+    bool IsPauseOnTrigger() const { return GetValue(PauseOnTriggerProperty); }
+
+    /// <summary>
+    /// Sets whether or not the emulator should be paused when the achievement triggers.
+    /// </summary>
+    void SetPauseOnTrigger(bool nValue) { SetValue(PauseOnTriggerProperty, nValue); }
+
+    // ===== Functions =====
+
+    void LoadAsset(AssetViewModelBase* pAsset);
+
+protected:
+    // ViewModelBase::NotifyTarget
+    void OnViewModelBoolValueChanged(const BoolModelProperty::ChangeArgs& args) override;
+    void OnViewModelStringValueChanged(const StringModelProperty::ChangeArgs& args) override;
+    void OnViewModelIntValueChanged(const IntModelProperty::ChangeArgs& args) override;
+
+    void OnValueChanged(const BoolModelProperty::ChangeArgs& args) override;
+    void OnValueChanged(const StringModelProperty::ChangeArgs& args) override;
+    void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
+
+    AssetViewModelBase* m_pAsset = nullptr;
+};
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
+
+#endif RA_UI_ASSETEDITOR_VIEW_MODEL_H
diff --git a/src/ui/viewmodels/AssetViewModelBase.hh b/src/ui/viewmodels/AssetViewModelBase.hh
index 0abded20..73ea9b66 100644
--- a/src/ui/viewmodels/AssetViewModelBase.hh
+++ b/src/ui/viewmodels/AssetViewModelBase.hh
@@ -134,7 +134,7 @@ public:
     void SetState(AssetState nValue) { SetValue(StateProperty, ra::etoi(nValue)); }
 
     /// <summary>
-    /// Gets whether or not tha asset is in an active state.
+    /// Gets whether or not the asset is in an active state.
     /// </summary>
     bool IsActive() const;
 


Test patch:
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index 43ccd4ab..69718e51 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -313,6 +313,7 @@
     <ClCompile Include="..\src\ui\TransactionalViewModelBase.cpp" />
     <ClCompile Include="..\src\ui\ViewModelCollection.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\AchievementViewModel.cpp" />
+    <ClCompile Include="..\src\ui\viewmodels\AssetEditorViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\AssetListViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\AssetViewModelBase.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\BrokenAchievementsViewModel.cpp" />
@@ -371,6 +372,7 @@
     <ClCompile Include="..\src\RA_Defs.cpp" />
     <ClCompile Include="..\src\RA_Leaderboard.cpp" />
     <ClCompile Include="ui\ViewModelCollection_Tests.cpp" />
+    <ClCompile Include="ui\viewmodels\AssetEditorViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\AssetListViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\AssetViewModelBase_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\BrokenAchievementsViewModel_Tests.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index 192d77b2..44d1121b 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -366,6 +366,12 @@
     <ClCompile Include="ui\viewmodels\AssetListViewModel_Tests.cpp">
       <Filter>Tests\UI\ViewModels</Filter>
     </ClCompile>
+    <ClCompile Include="ui\viewmodels\AssetEditorViewModel_Tests.cpp">
+      <Filter>Tests\UI\ViewModels</Filter>
+    </ClCompile>
+    <ClCompile Include="..\src\ui\viewmodels\AssetEditorViewModel.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
diff --git a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
new file mode 100644
index 00000000..a510ba7c
--- /dev/null
+++ b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
@@ -0,0 +1,346 @@
+#include "CppUnitTest.h"
+
+#include "ui\viewmodels\AssetEditorViewModel.hh"
+
+#include "tests\RA_UnitTestHelpers.h"
+
+using namespace Microsoft::VisualStudio::CppUnitTestFramework;
+
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+namespace tests {
+
+TEST_CLASS(AssetEditorViewModel_Tests)
+{
+private:
+    class AssetEditorViewModelHarness : public AssetEditorViewModel
+    {
+    public:
+        AssetEditorViewModelHarness() = default;
+        ~AssetEditorViewModelHarness()
+        {
+            // prevent exception if asset is destroyed before harness
+            m_pAsset = nullptr;
+        }
+
+        AssetEditorViewModelHarness(const AssetEditorViewModelHarness&) noexcept = delete;
+        AssetEditorViewModelHarness& operator=(const AssetEditorViewModelHarness&) noexcept = delete;
+        AssetEditorViewModelHarness(AssetEditorViewModelHarness&&) noexcept = delete;
+        AssetEditorViewModelHarness& operator=(AssetEditorViewModelHarness&&) noexcept = delete;
+    };
+
+
+public:
+    TEST_METHOD(TestInitialValues)
+    {
+        AssetEditorViewModelHarness editor;
+
+        Assert::AreEqual(std::wstring(L"Asset Editor"), editor.GetWindowTitle());
+        Assert::AreEqual(0U, editor.GetID());
+        Assert::AreEqual(std::wstring(L"[No Asset Loaded]"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"Open an asset from the Assets List"), editor.GetDescription());
+        Assert::AreEqual(AssetCategory::Core, editor.GetCategory());
+        Assert::AreEqual(AssetState::Inactive, editor.GetState());
+        Assert::AreEqual(0, editor.GetPoints());
+        Assert::AreEqual(std::wstring(L"00000"), editor.GetBadge());
+        Assert::IsFalse(editor.IsPauseOnReset());
+        Assert::IsFalse(editor.IsPauseOnTrigger());
+        Assert::IsFalse(editor.IsAssetLoaded());
+    }
+
+    TEST_METHOD(TestLoadAchievement)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetName(L"Test Achievement");
+        achievement.SetID(1234U);
+        achievement.SetState(AssetState::Active);
+        achievement.SetDescription(L"Do something cool");
+        achievement.SetCategory(AssetCategory::Unofficial);
+        achievement.SetPoints(10);
+        achievement.SetBadge(L"58329");
+
+        editor.LoadAsset(&achievement);
+
+        Assert::AreEqual(std::wstring(L"Achievement Editor"), editor.GetWindowTitle());
+        Assert::AreEqual(1234U, editor.GetID());
+        Assert::AreEqual(std::wstring(L"Test Achievement"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"Do something cool"), editor.GetDescription());
+        Assert::AreEqual(AssetCategory::Unofficial, editor.GetCategory());
+        Assert::AreEqual(AssetState::Active, editor.GetState());
+        Assert::AreEqual(10, editor.GetPoints());
+        Assert::AreEqual(std::wstring(L"58329"), editor.GetBadge());
+        Assert::IsFalse(editor.IsPauseOnReset());
+        Assert::IsFalse(editor.IsPauseOnTrigger());
+        Assert::IsTrue(editor.IsAssetLoaded());
+
+        editor.LoadAsset(nullptr);
+
+        Assert::AreEqual(std::wstring(L"Asset Editor"), editor.GetWindowTitle());
+        Assert::AreEqual(0U, editor.GetID());
+        Assert::AreEqual(std::wstring(L"[No Asset Loaded]"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"Open an asset from the Assets List"), editor.GetDescription());
+        Assert::AreEqual(AssetCategory::Core, editor.GetCategory());
+        Assert::AreEqual(AssetState::Inactive, editor.GetState());
+        Assert::AreEqual(0, editor.GetPoints());
+        Assert::AreEqual(std::wstring(L"00000"), editor.GetBadge());
+        Assert::IsFalse(editor.IsPauseOnReset());
+        Assert::IsFalse(editor.IsPauseOnTrigger());
+        Assert::IsFalse(editor.IsAssetLoaded());
+    }
+
+    TEST_METHOD(TestSyncId)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetID(1234U);
+
+        editor.LoadAsset(&achievement);
+        Assert::AreEqual(1234U, editor.GetID());
+        Assert::AreEqual(1234U, achievement.GetID());
+
+        achievement.SetID(2345U);
+        Assert::AreEqual(2345U, editor.GetID());
+        Assert::AreEqual(2345U, achievement.GetID());
+    }
+
+    TEST_METHOD(TestLocalIdIgnored)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetCategory(AssetCategory::Local);
+        achievement.SetID(11100002U);
+
+        editor.LoadAsset(&achievement);
+        Assert::AreEqual(0U, editor.GetID());
+        Assert::AreEqual(11100002U, achievement.GetID());
+
+        achievement.SetID(2345U);
+        Assert::AreEqual(2345U, editor.GetID());
+        Assert::AreEqual(2345U, achievement.GetID());
+    }
+
+    TEST_METHOD(TestSyncName)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetName(L"Test Achievement");
+
+        editor.LoadAsset(&achievement);
+        Assert::AreEqual(std::wstring(L"Test Achievement"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"Test Achievement"), achievement.GetName());
+
+        editor.SetName(L"New Name");
+        Assert::AreEqual(std::wstring(L"New Name"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"New Name"), achievement.GetName());
+
+        editor.SetName(L"New Name 2");
+        Assert::AreEqual(std::wstring(L"New Name 2"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"New Name 2"), achievement.GetName());
+
+        achievement.SetName(L"New Name 3");
+        Assert::AreEqual(std::wstring(L"New Name 3"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"New Name 3"), achievement.GetName());
+    }
+
+    TEST_METHOD(TestSyncDescription)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetDescription(L"Do something cool");
+
+        editor.LoadAsset(&achievement);
+        Assert::AreEqual(std::wstring(L"Do something cool"), editor.GetDescription());
+        Assert::AreEqual(std::wstring(L"Do something cool"), achievement.GetDescription());
+
+        editor.SetDescription(L"New description");
+        Assert::AreEqual(std::wstring(L"New description"), editor.GetDescription());
+        Assert::AreEqual(std::wstring(L"New description"), achievement.GetDescription());
+
+        editor.SetDescription(L"New description 2");
+        Assert::AreEqual(std::wstring(L"New description 2"), editor.GetDescription());
+        Assert::AreEqual(std::wstring(L"New description 2"), achievement.GetDescription());
+
+        achievement.SetDescription(L"New description 3");
+        Assert::AreEqual(std::wstring(L"New description 3"), editor.GetDescription());
+        Assert::AreEqual(std::wstring(L"New description 3"), achievement.GetDescription());
+    }
+
+    TEST_METHOD(TestSyncState)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetState(AssetState::Active);
+
+        editor.LoadAsset(&achievement);
+        Assert::AreEqual(AssetState::Active, editor.GetState());
+        Assert::AreEqual(AssetState::Active, achievement.GetState());
+
+        editor.SetState(AssetState::Paused);
+        Assert::AreEqual(AssetState::Paused, editor.GetState());
+        Assert::AreEqual(AssetState::Paused, achievement.GetState());
+
+        editor.SetState(AssetState::Triggered);
+        Assert::AreEqual(AssetState::Triggered, editor.GetState());
+        Assert::AreEqual(AssetState::Triggered, achievement.GetState());
+
+        achievement.SetState(AssetState::Waiting);
+        Assert::AreEqual(AssetState::Waiting, editor.GetState());
+        Assert::AreEqual(AssetState::Waiting, achievement.GetState());
+    }
+
+    TEST_METHOD(TestSyncCategory)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetCategory(AssetCategory::Unofficial);
+
+        editor.LoadAsset(&achievement);
+        Assert::AreEqual(AssetCategory::Unofficial, editor.GetCategory());
+        Assert::AreEqual(AssetCategory::Unofficial, achievement.GetCategory());
+
+        editor.SetCategory(AssetCategory::Core);
+        Assert::AreEqual(AssetCategory::Core, editor.GetCategory());
+        Assert::AreEqual(AssetCategory::Core, achievement.GetCategory());
+
+        editor.SetCategory(AssetCategory::Unofficial);
+        Assert::AreEqual(AssetCategory::Unofficial, editor.GetCategory());
+        Assert::AreEqual(AssetCategory::Unofficial, achievement.GetCategory());
+
+        achievement.SetCategory(AssetCategory::Core);
+        Assert::AreEqual(AssetCategory::Core, editor.GetCategory());
+        Assert::AreEqual(AssetCategory::Core, achievement.GetCategory());
+    }
+
+    TEST_METHOD(TestSyncPoints)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetPoints(10);
+
+        editor.LoadAsset(&achievement);
+        Assert::AreEqual(10, editor.GetPoints());
+        Assert::AreEqual(10, achievement.GetPoints());
+
+        editor.SetPoints(25);
+        Assert::AreEqual(25, editor.GetPoints());
+        Assert::AreEqual(25, achievement.GetPoints());
+
+        editor.SetPoints(5);
+        Assert::AreEqual(5, editor.GetPoints());
+        Assert::AreEqual(5, achievement.GetPoints());
+
+        editor.SetPoints(50);
+        Assert::AreEqual(50, editor.GetPoints());
+        Assert::AreEqual(50, achievement.GetPoints());
+    }
+
+    TEST_METHOD(TestSyncBadge)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetBadge(L"58329");
+
+        editor.LoadAsset(&achievement);
+        Assert::AreEqual(std::wstring(L"58329"), editor.GetBadge());
+        Assert::AreEqual(std::wstring(L"58329"), achievement.GetBadge());
+
+        editor.SetBadge(L"12345");
+        Assert::AreEqual(std::wstring(L"12345"), editor.GetBadge());
+        Assert::AreEqual(std::wstring(L"12345"), achievement.GetBadge());
+
+        editor.SetBadge(L"34567");
+        Assert::AreEqual(std::wstring(L"34567"), editor.GetBadge());
+        Assert::AreEqual(std::wstring(L"34567"), achievement.GetBadge());
+
+        achievement.SetBadge(L"67890");
+        Assert::AreEqual(std::wstring(L"67890"), editor.GetBadge());
+        Assert::AreEqual(std::wstring(L"67890"), achievement.GetBadge());
+    }
+
+    TEST_METHOD(TestSyncPauseOnReset)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetPauseOnReset(true);
+
+        editor.LoadAsset(&achievement);
+        Assert::IsTrue(editor.IsPauseOnReset());
+        Assert::IsTrue(achievement.IsPauseOnReset());
+
+        editor.SetPauseOnReset(false);
+        Assert::IsFalse(editor.IsPauseOnReset());
+        Assert::IsFalse(achievement.IsPauseOnReset());
+
+        editor.SetPauseOnReset(true);
+        Assert::IsTrue(editor.IsPauseOnReset());
+        Assert::IsTrue(achievement.IsPauseOnReset());
+
+        editor.SetPauseOnReset(false);
+        Assert::IsFalse(editor.IsPauseOnReset());
+        Assert::IsFalse(achievement.IsPauseOnReset());
+    }
+
+    TEST_METHOD(TestSyncPauseOnTrigger)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetPauseOnTrigger(true);
+
+        editor.LoadAsset(&achievement);
+        Assert::IsTrue(editor.IsPauseOnTrigger());
+        Assert::IsTrue(achievement.IsPauseOnTrigger());
+
+        editor.SetPauseOnTrigger(false);
+        Assert::IsFalse(editor.IsPauseOnTrigger());
+        Assert::IsFalse(achievement.IsPauseOnTrigger());
+
+        editor.SetPauseOnTrigger(true);
+        Assert::IsTrue(editor.IsPauseOnTrigger());
+        Assert::IsTrue(achievement.IsPauseOnTrigger());
+
+        editor.SetPauseOnTrigger(false);
+        Assert::IsFalse(editor.IsPauseOnTrigger());
+        Assert::IsFalse(achievement.IsPauseOnTrigger());
+    }
+
+    TEST_METHOD(TestSyncOneRecordAtATime)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement1;
+        achievement1.SetName(L"Achievement1");
+        AchievementViewModel achievement2;
+        achievement2.SetName(L"Achievement2");
+
+        editor.LoadAsset(&achievement1);
+        Assert::AreEqual(std::wstring(L"Achievement1"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"Achievement1"), achievement1.GetName());
+        Assert::AreEqual(std::wstring(L"Achievement2"), achievement2.GetName());
+
+        editor.SetName(L"New Name");
+        Assert::AreEqual(std::wstring(L"New Name"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"New Name"), achievement1.GetName());
+        Assert::AreEqual(std::wstring(L"Achievement2"), achievement2.GetName());
+
+        editor.LoadAsset(&achievement2);
+        Assert::AreEqual(std::wstring(L"Achievement2"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"New Name"), achievement1.GetName());
+        Assert::AreEqual(std::wstring(L"Achievement2"), achievement2.GetName());
+
+        editor.SetName(L"New Name 2");
+        Assert::AreEqual(std::wstring(L"New Name 2"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"New Name"), achievement1.GetName());
+        Assert::AreEqual(std::wstring(L"New Name 2"), achievement2.GetName());
+
+        achievement1.SetName(L"New Name 3");
+        Assert::AreEqual(std::wstring(L"New Name 2"), editor.GetName());
+        Assert::AreEqual(std::wstring(L"New Name 3"), achievement1.GetName());
+        Assert::AreEqual(std::wstring(L"New Name 2"), achievement2.GetName());
+    }};
+
+} // namespace tests
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra