Files
PowerToys/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/AppStateServiceTests.cs
Michael Jolley 4337f8e5ff CmdPal: Make settings and app state immutable (#46451)
## Summary
 
 This PR refactors CmdPal settings/state to be immutable end-to-end.
 
 ### Core changes
 - Convert model types to immutable records / init-only properties:
   - `SettingsModel`
   - `AppStateModel`
   - `ProviderSettings`
   - `DockSettings`
   - `RecentCommandsManager`
- supporting settings types (fallback/hotkey/alias/top-level
hotkey/history items, etc.)
- Replace mutable collections with immutable equivalents where
appropriate:
   - `ImmutableDictionary<,>`
   - `ImmutableList<>`
 - Move mutation flow to atomic service updates:
- `ISettingsService.UpdateSettings(Func<SettingsModel, SettingsModel>)`
   - `IAppStateService.UpdateState(Func<AppStateModel, AppStateModel>)`
- Update ViewModels/managers/services to use copy-on-write (`with`)
patterns instead of in-place
mutation.
- Update serialization context + tests for immutable model graph
compatibility.
 
 ## Why
 
Issue #46437 is caused by mutable shared state being updated from
different execution paths/threads,
leading to race-prone behavior during persistence/serialization.
 
By making settings/app state immutable and using atomic swap/update
patterns, we remove in-place
mutation and eliminate this class of concurrency bug.
 
 ## Validation
 
 - Built successfully:
   - `Microsoft.CmdPal.UI.ViewModels`
   - `Microsoft.CmdPal.UI`
   - `Microsoft.CmdPal.UI.ViewModels.UnitTests`
 - Updated unit tests for immutable update patterns.
 
 Fixes #46437

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 17:54:58 +00:00

206 lines
6.5 KiB
C#

// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class AppStateServiceTests
{
private Mock<IPersistenceService> _mockPersistence = null!;
private Mock<IApplicationInfoService> _mockAppInfo = null!;
private string _testDirectory = null!;
[TestInitialize]
public void Setup()
{
_mockPersistence = new Mock<IPersistenceService>();
_mockAppInfo = new Mock<IApplicationInfoService>();
_testDirectory = Path.Combine(Path.GetTempPath(), $"CmdPalTest_{Guid.NewGuid():N}");
_mockAppInfo.Setup(a => a.ConfigDirectory).Returns(_testDirectory);
// Default: Load returns a new AppStateModel
_mockPersistence
.Setup(p => p.Load(
It.IsAny<string>(),
It.IsAny<System.Text.Json.Serialization.Metadata.JsonTypeInfo<AppStateModel>>()))
.Returns(new AppStateModel());
}
[TestMethod]
public void Constructor_LoadsState_ViaPersistenceService()
{
// Arrange
var expectedState = new AppStateModel
{
RunHistory = ImmutableList.Create("command1", "command2"),
};
_mockPersistence
.Setup(p => p.Load(
It.IsAny<string>(),
It.IsAny<System.Text.Json.Serialization.Metadata.JsonTypeInfo<AppStateModel>>()))
.Returns(expectedState);
// Act
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
// Assert
Assert.IsNotNull(service.State);
Assert.AreEqual(2, service.State.RunHistory.Count);
Assert.AreEqual("command1", service.State.RunHistory[0]);
_mockPersistence.Verify(
p => p.Load(
It.IsAny<string>(),
It.IsAny<System.Text.Json.Serialization.Metadata.JsonTypeInfo<AppStateModel>>()),
Times.Once);
}
[TestMethod]
public void State_ReturnsLoadedModel()
{
// Arrange
var expectedState = new AppStateModel();
_mockPersistence
.Setup(p => p.Load(
It.IsAny<string>(),
It.IsAny<System.Text.Json.Serialization.Metadata.JsonTypeInfo<AppStateModel>>()))
.Returns(expectedState);
// Act
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
// Assert
Assert.AreSame(expectedState, service.State);
}
[TestMethod]
public void Save_DelegatesToPersistenceService()
{
// Arrange
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
service.UpdateState(s => s with { RunHistory = s.RunHistory.Add("test-command") });
_mockPersistence.Invocations.Clear(); // Reset after Arrange — UpdateState also persists
// Act
service.Save();
// Assert
_mockPersistence.Verify(
p => p.Save(
service.State,
It.IsAny<string>(),
It.IsAny<System.Text.Json.Serialization.Metadata.JsonTypeInfo<AppStateModel>>()),
Times.Once);
}
[TestMethod]
public void Save_RaisesStateChangedEvent()
{
// Arrange
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
var eventRaised = false;
service.StateChanged += (sender, state) =>
{
eventRaised = true;
};
// Act
service.Save();
// Assert
Assert.IsTrue(eventRaised);
}
[TestMethod]
public void StateChanged_PassesCorrectArguments()
{
// Arrange
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
IAppStateService? receivedSender = null;
AppStateModel? receivedState = null;
service.StateChanged += (sender, state) =>
{
receivedSender = sender;
receivedState = state;
};
// Act
service.Save();
// Assert
Assert.AreSame(service, receivedSender);
Assert.AreSame(service.State, receivedState);
}
[TestMethod]
public void Save_Always_RaisesStateChangedEvent()
{
// Arrange - AppStateService.Save() should always raise StateChanged
// (unlike SettingsService which has hotReload parameter)
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
var eventCount = 0;
service.StateChanged += (sender, state) =>
{
eventCount++;
};
// Act
service.Save();
service.Save();
// Assert
Assert.AreEqual(2, eventCount);
}
[TestMethod]
public void UpdateState_ConcurrentUpdates_NoLostUpdates()
{
// Arrange — two threads each add items to RunHistory concurrently.
// With the CAS loop, every add must land (no lost updates).
var service = new AppStateService(_mockPersistence.Object, _mockAppInfo.Object);
const int iterations = 50;
var barrier = new System.Threading.Barrier(2);
// Act
var t1 = System.Threading.Tasks.Task.Run(() =>
{
barrier.SignalAndWait();
for (var i = 0; i < iterations; i++)
{
service.UpdateState(s => s with { RunHistory = s.RunHistory.Add($"t1-{i}") });
}
});
var t2 = System.Threading.Tasks.Task.Run(() =>
{
barrier.SignalAndWait();
for (var i = 0; i < iterations; i++)
{
service.UpdateState(s => s with { RunHistory = s.RunHistory.Add($"t2-{i}") });
}
});
System.Threading.Tasks.Task.WaitAll(t1, t2);
// Assert — all 100 items must be present (no lost updates)
Assert.AreEqual(iterations * 2, service.State.RunHistory.Count, "All concurrent updates should be preserved");
for (var i = 0; i < iterations; i++)
{
Assert.IsTrue(service.State.RunHistory.Contains($"t1-{i}"), $"Missing t1-{i}");
Assert.IsTrue(service.State.RunHistory.Contains($"t2-{i}"), $"Missing t2-{i}");
}
}
}