diff --git a/Kepware.Api.Test/Kepware.Api.Test.csproj b/Kepware.Api.Test/Kepware.Api.Test.csproj
index 9827459..6b37f56 100644
--- a/Kepware.Api.Test/Kepware.Api.Test.csproj
+++ b/Kepware.Api.Test/Kepware.Api.Test.csproj
@@ -29,6 +29,11 @@
+
+
+
+
+
diff --git a/Kepware.Api.Test/Serializer/RoundtripIdempotencyTests.cs b/Kepware.Api.Test/Serializer/RoundtripIdempotencyTests.cs
new file mode 100644
index 0000000..2e7b8ab
--- /dev/null
+++ b/Kepware.Api.Test/Serializer/RoundtripIdempotencyTests.cs
@@ -0,0 +1,214 @@
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Kepware.Api.Model;
+using Kepware.Api.Serializer;
+using Kepware.Api.Util;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Shouldly;
+
+namespace Kepware.Api.Test.Serializer
+{
+ public class RoundtripIdempotencyTests
+ {
+ private static YamlSerializer CreateYamlSerializer() =>
+ new(Mock.Of>());
+
+ private static CsvTagSerializer CreateCsvTagSerializer() =>
+ new(Mock.Of>());
+
+ private static DataTypeEnumConverterProvider CreateDataTypeConverterProvider() => new();
+
+ [Fact]
+ public async Task YamlSerializer_Roundtrip_ProjectEntities_ShouldPreserveHashes()
+ {
+ var serializer = CreateYamlSerializer();
+ var tempRoot = Path.Combine(Path.GetTempPath(), nameof(YamlSerializer_Roundtrip_ProjectEntities_ShouldPreserveHashes), Path.GetRandomFileName());
+
+ try
+ {
+ var project = CreateProjectEntity();
+ var channel = CreateChannelEntity();
+ var device = CreateDeviceEntity();
+ var projectFile = Path.Combine(tempRoot, "project", "project.yaml");
+ var channelFile = Path.Combine(tempRoot, channel.Name, "channel.yaml");
+ var deviceFile = Path.Combine(tempRoot, channel.Name, device.Name, "device.yaml");
+
+ await serializer.SaveAsYaml(projectFile, project);
+ await serializer.SaveAsYaml(channelFile, channel);
+ await serializer.SaveAsYaml(deviceFile, device);
+
+ var savedChannelYaml = await File.ReadAllTextAsync(channelFile);
+ var savedDeviceYaml = await File.ReadAllTextAsync(deviceFile);
+
+ var loadedProject = await serializer.LoadFromYaml(projectFile);
+ var loadedChannel = await serializer.LoadFromYaml(channelFile);
+ var loadedDevice = await serializer.LoadFromYaml(deviceFile);
+
+ loadedProject.Description.ShouldBe(project.Description);
+ loadedProject.ProjectProperties.Title.ShouldBe(project.ProjectProperties.Title);
+
+ loadedChannel.Hash.ShouldBe(channel.Hash);
+ loadedChannel.Name.ShouldBe(channel.Name);
+ loadedChannel.Description.ShouldBe(channel.Description);
+ loadedChannel.DeviceDriver.ShouldBe(channel.DeviceDriver);
+
+ loadedDevice.Hash.ShouldBe(device.Hash);
+ loadedDevice.Name.ShouldBe(device.Name);
+ loadedDevice.Description.ShouldBe(device.Description);
+ loadedDevice.GetDynamicProperty(Properties.Channel.DeviceDriver).ShouldBe(device.GetDynamicProperty(Properties.Channel.DeviceDriver));
+
+ await serializer.SaveAsYaml(channelFile, loadedChannel);
+ await serializer.SaveAsYaml(deviceFile, loadedDevice);
+
+ (await File.ReadAllTextAsync(channelFile)).ShouldBe(savedChannelYaml);
+ (await File.ReadAllTextAsync(deviceFile)).ShouldBe(savedDeviceYaml);
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, recursive: true);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task CsvTagSerializer_Roundtrip_Tags_ShouldNotIntroduceHashDifferences()
+ {
+ var serializer = CreateCsvTagSerializer();
+ var converter = CreateDataTypeConverterProvider().GetDataTypeEnumConverter("Simulator");
+ var tempRoot = Path.Combine(Path.GetTempPath(), nameof(CsvTagSerializer_Roundtrip_Tags_ShouldNotIntroduceHashDifferences), Path.GetRandomFileName());
+ var tagsFile = Path.Combine(tempRoot, "tags.csv");
+ var secondTagsFile = Path.Combine(tempRoot, "tags-roundtrip.csv");
+
+ Directory.CreateDirectory(tempRoot);
+
+ try
+ {
+ var sourceTags = new DeviceTagCollection
+ {
+ CreateScaledTag("ScaledTag"),
+ CreateUnscaledTag("DiscreteTag")
+ };
+
+ await serializer.ExportTagsAsync(tagsFile, sourceTags.ToList(), converter);
+ var importedTags = await serializer.ImportTagsAsync(tagsFile, converter);
+ await serializer.ExportTagsAsync(secondTagsFile, importedTags, converter);
+
+ importedTags.Count.ShouldBe(sourceTags.Count);
+
+ foreach (var sourceTag in sourceTags)
+ {
+ var importedTag = importedTags.Single(tag => tag.Name == sourceTag.Name);
+ importedTag.Hash.ShouldBe(sourceTag.Hash);
+ importedTag.Description.ShouldBe(sourceTag.Description);
+ importedTag.TagAddress.ShouldBe(sourceTag.TagAddress);
+ importedTag.DataType.ShouldBe(sourceTag.DataType);
+ importedTag.ReadWriteAccess.ShouldBe(sourceTag.ReadWriteAccess);
+ importedTag.ScanRateMilliseconds.ShouldBe(sourceTag.ScanRateMilliseconds);
+ importedTag.ScalingType.ShouldBe(sourceTag.ScalingType);
+ importedTag.ScalingUnits.ShouldBe(sourceTag.ScalingUnits);
+ importedTag.ScalingClampLow.ShouldBe(sourceTag.ScalingClampLow);
+ importedTag.ScalingClampHigh.ShouldBe(sourceTag.ScalingClampHigh);
+ importedTag.ScalingNegateValue.ShouldBe(sourceTag.ScalingNegateValue);
+ }
+
+ var compareResult = EntityCompare.Compare(sourceTags, [.. importedTags]);
+ compareResult.ChangedItems.ShouldBeEmpty();
+ compareResult.ItemsOnlyInLeft.ShouldBeEmpty();
+ compareResult.ItemsOnlyInRight.ShouldBeEmpty();
+ compareResult.UnchangedItems.Count.ShouldBe(sourceTags.Count);
+
+ (await File.ReadAllTextAsync(secondTagsFile)).ShouldBe(await File.ReadAllTextAsync(tagsFile));
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, recursive: true);
+ }
+ }
+ }
+
+ private static Project CreateProjectEntity()
+ {
+ var project = new Project
+ {
+ Description = "Project description"
+ };
+ project.ProjectProperties.Title = "Project title";
+ project.SetDynamicProperty(Properties.ProjectSettings.OpcDa.EnableOpcDa3, true);
+ return project;
+ }
+
+ private static Channel CreateChannelEntity()
+ {
+ var channel = new Channel
+ {
+ Name = "Channel-01",
+ Description = "Channel description",
+ DeviceDriver = "Simulator",
+ DiagnosticsCapture = true,
+ };
+ channel.SetDynamicProperty(Properties.NonUpdatable.ChannelUniqueId, 101L);
+ return channel;
+ }
+
+ private static Device CreateDeviceEntity()
+ {
+ var device = new Device
+ {
+ Name = "Device-01",
+ Description = "Device description",
+ };
+ device.SetDynamicProperty(Properties.NonUpdatable.DeviceUniqueId, 201L);
+ device.SetDynamicProperty(Properties.Channel.DeviceDriver, "Simulator");
+ device.SetDynamicProperty(Properties.Device.DeviceDriver, "Simulator");
+ return device;
+ }
+
+ private static Tag CreateScaledTag(string name)
+ {
+ var tag = new Tag
+ {
+ Name = name,
+ Description = "Scaled tag"
+ };
+
+ tag.TagAddress = "RAMP";
+ tag.DataType = 8;
+ tag.ReadWriteAccess = 1;
+ tag.ScanRateMilliseconds = 250;
+ tag.ScalingType = 1;
+ tag.ScalingRawLow = 0;
+ tag.ScalingRawHigh = 100;
+ tag.ScalingScaledLow = 0;
+ tag.ScalingScaledHigh = 1000;
+ tag.ScalingScaledDataType = 8;
+ tag.ScalingClampLow = true;
+ tag.ScalingClampHigh = false;
+ tag.ScalingUnits = "psi";
+ tag.ScalingNegateValue = true;
+
+ return tag;
+ }
+
+ private static Tag CreateUnscaledTag(string name)
+ {
+ var tag = new Tag
+ {
+ Name = name,
+ Description = "Discrete tag"
+ };
+
+ tag.TagAddress = "SWITCH";
+ tag.DataType = 1;
+ tag.ReadWriteAccess = 0;
+ tag.ScanRateMilliseconds = 100;
+ tag.ScalingType = 0;
+ return tag;
+ }
+ }
+}
diff --git a/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs b/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs
new file mode 100644
index 0000000..806be00
--- /dev/null
+++ b/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs
@@ -0,0 +1,417 @@
+#if NET10_0_OR_GREATER
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Kepware.Api.Model;
+using Kepware.Api.Serializer;
+using Kepware.Api.Test.ApiClient;
+using Kepware.Api.Util;
+using Kepware.SyncService;
+using Kepware.SyncService.Configuration;
+using Kepware.SyncService.ProjectStorage;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Moq.Contrib.HttpClient;
+using Shouldly;
+
+namespace Kepware.Api.Test.Sync
+{
+ public class SyncIdempotencyTests : TestApiClientBase
+ {
+ private readonly YamlSerializer _yamlSerializer = new(Mock.Of>());
+ private readonly CsvTagSerializer _csvTagSerializer = new(Mock.Of>());
+
+ [Fact]
+ public async Task KepFolderStorage_ProjectRoundtrip_ShouldBeIdempotent()
+ {
+ var project = CreateStorageProjectGraph();
+ var tempRoot = CreateTempDirectory(nameof(KepFolderStorage_ProjectRoundtrip_ShouldBeIdempotent));
+
+ try
+ {
+ var storage = CreateStorage(tempRoot);
+
+ await storage.ExportProjecAsync(project);
+ var firstLoad = await storage.LoadProject(true);
+ AssignOwners(firstLoad);
+
+ var firstProjectYaml = await File.ReadAllTextAsync(Path.Combine(tempRoot, "project.yaml"));
+ var firstDeviceTagsCsv = await File.ReadAllTextAsync(Path.Combine(tempRoot, "Channel_Main", "Device_Main", "tags.csv"));
+ AssertProjectsEquivalent(project, firstLoad);
+
+ await storage.ExportProjecAsync(firstLoad);
+ var secondLoad = await storage.LoadProject(true);
+ AssignOwners(secondLoad);
+
+ AssertProjectsEquivalent(firstLoad, secondLoad);
+ AssertProjectsEquivalent(project, secondLoad);
+
+ (await File.ReadAllTextAsync(Path.Combine(tempRoot, "project.yaml"))).ShouldBe(firstProjectYaml);
+ (await File.ReadAllTextAsync(Path.Combine(tempRoot, "Channel_Main", "Device_Main", "tags.csv"))).ShouldBe(firstDeviceTagsCsv);
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, recursive: true);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task CompareAndApply_RoundtripReloadedProject_SecondRun_ShouldReportNoChanges()
+ {
+ var tempRoot = CreateTempDirectory(nameof(CompareAndApply_RoundtripReloadedProject_SecondRun_ShouldReportNoChanges));
+
+ try
+ {
+ var storage = CreateStorage(tempRoot);
+ var sourceProject = CreateProjectGraph();
+ await storage.ExportProjecAsync(sourceProject);
+
+ var roundtrippedProject = await storage.LoadProject(true);
+ AssignOwners(roundtrippedProject);
+
+ var targetProject = await roundtrippedProject.CloneAsync();
+ AssignOwners(targetProject);
+
+ var sourceTag = roundtrippedProject.Channels![0].Devices![0].Tags![0];
+ var targetTag = targetProject.Channels![0].Devices![0].Tags![0];
+ targetTag.Description = "Outdated description";
+
+ var tagEndpoint = TEST_ENDPOINT + "/config/v1/project/channels/Channel_Main/devices/Device_Main/tags/Tag_Main";
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, tagEndpoint)
+ .ReturnsResponse(HttpStatusCode.OK, JsonSerializer.Serialize(targetTag, KepJsonContext.Default.Tag), "application/json");
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Put, tagEndpoint)
+ .ReturnsResponse(HttpStatusCode.OK);
+
+ var firstResult = await _kepwareApiClient.Project.CompareAndApplyDetailedAsync(roundtrippedProject, targetProject);
+ firstResult.Updates.ShouldBe(1);
+ firstResult.Inserts.ShouldBe(0);
+ firstResult.Deletes.ShouldBe(0);
+ firstResult.Failures.ShouldBe(0);
+
+ var appliedProject = await roundtrippedProject.CloneAsync();
+ AssignOwners(appliedProject);
+
+ var secondResult = await _kepwareApiClient.Project.CompareAndApplyDetailedAsync(roundtrippedProject, appliedProject);
+ secondResult.Updates.ShouldBe(0);
+ secondResult.Inserts.ShouldBe(0);
+ secondResult.Deletes.ShouldBe(0);
+ secondResult.Failures.ShouldBe(0);
+ }
+ finally
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, recursive: true);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task CompareAndApply_ProjectHashMismatchWithoutProjectPropertyDiff_ShouldReportNoChanges()
+ {
+ ConfigureConnectedClient();
+
+ var sourceProject = CreateProjectPropertiesOnly(string.Empty);
+ sourceProject.Description = "Transient description";
+ _ = sourceProject.Hash;
+ sourceProject.Description = string.Empty;
+
+ var targetProject = await sourceProject.CloneAsync();
+ AssignOwners(targetProject);
+
+ var targetProjectJson = JsonSerializer.Serialize(targetProject, KepJsonContext.Default.Project);
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project")
+ .ReturnsResponse(HttpStatusCode.OK, targetProjectJson, "application/json");
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Put, TEST_ENDPOINT + "/config/v1/project")
+ .ReturnsResponse(HttpStatusCode.OK);
+
+ var result = await _kepwareApiClient.Project.CompareAndApplyDetailedAsync(sourceProject, targetProject);
+
+ result.Updates.ShouldBe(0);
+ result.Inserts.ShouldBe(0);
+ result.Deletes.ShouldBe(0);
+ result.Failures.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task SyncService_DiskToPrimaryFollowedByPrimarySync_ShouldNotLoopWhenNoEffectiveChangesRemain()
+ {
+ ConfigureConnectedClient();
+
+ var diskProject = CreateProjectPropertiesOnly("Desired description");
+ var staleProject = CreateProjectPropertiesOnly("Previous description");
+ var updatedProject = CreateProjectPropertiesOnly("Desired description");
+
+ var staleProjectJson = JsonSerializer.Serialize(staleProject, KepJsonContext.Default.Project);
+ var staleFullProjectJson = JsonSerializer.Serialize(new JsonProjectRoot { Project = staleProject }, KepJsonContext.Default.JsonProjectRoot);
+ var updatedProjectJson = JsonSerializer.Serialize(updatedProject, KepJsonContext.Default.Project);
+ var updatedFullProjectJson = JsonSerializer.Serialize(new JsonProjectRoot { Project = updatedProject }, KepJsonContext.Default.JsonProjectRoot);
+
+ var firstStorage = new FakeProjectStorage(diskProject);
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project")
+ .ReturnsResponse(HttpStatusCode.OK, staleProjectJson, "application/json");
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project?content=serialize")
+ .ReturnsResponse(HttpStatusCode.OK, staleFullProjectJson, "application/json");
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Put, TEST_ENDPOINT + "/config/v1/project")
+ .ReturnsResponse(HttpStatusCode.OK);
+
+ using var firstService = CreateSyncService(firstStorage);
+ await firstService.SyncFromLocalFileAsync();
+
+ GetQueuedEvents(firstService).Select(change => change.Source).ShouldBe([ChangeSource.PrimaryKepServer]);
+
+ var followUpStorage = new FakeProjectStorage(diskProject);
+ _httpMessageHandlerMock.Reset();
+ ConfigureConnectedClient();
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project")
+ .ReturnsResponse(HttpStatusCode.OK, updatedProjectJson, "application/json");
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project?content=serialize")
+ .ReturnsResponse(HttpStatusCode.OK, updatedFullProjectJson, "application/json");
+
+ using var followUpService = CreateSyncService(followUpStorage);
+ await followUpService.SyncFromPrimaryKepServerAsync();
+
+ followUpStorage.ExportCount.ShouldBe(1);
+ followUpStorage.LastExportedProject.ShouldNotBeNull();
+ GetQueuedEvents(followUpService).ShouldBeEmpty();
+
+ var stableStorage = new FakeProjectStorage(diskProject);
+ _httpMessageHandlerMock.Reset();
+ ConfigureConnectedClient();
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project")
+ .ReturnsResponse(HttpStatusCode.OK, updatedProjectJson, "application/json");
+ _httpMessageHandlerMock.SetupRequest(HttpMethod.Get, TEST_ENDPOINT + "/config/v1/project?content=serialize")
+ .ReturnsResponse(HttpStatusCode.OK, updatedFullProjectJson, "application/json");
+
+ using var stableService = CreateSyncService(stableStorage);
+ await stableService.SyncFromLocalFileAsync();
+
+ GetQueuedEvents(stableService).ShouldBeEmpty();
+ }
+
+ private KepFolderStorage CreateStorage(string directory)
+ {
+ return new KepFolderStorage(
+ NullLogger.Instance,
+ new KepStorageOptions { Directory = directory },
+ _yamlSerializer,
+ _csvTagSerializer);
+ }
+
+ private Kepware.SyncService.SyncService CreateSyncService(IProjectStorage storage)
+ {
+ return new Kepware.SyncService.SyncService(
+ _kepwareApiClient,
+ [],
+ storage,
+ new KepSyncOptions
+ {
+ SyncDirection = SyncDirection.KepwareToDiskAndSecondary,
+ SyncMode = SyncMode.TwoWay,
+ SyncThrottlingMs = 0
+ },
+ Mock.Of>());
+ }
+
+ private static string CreateTempDirectory(string testName)
+ {
+ var directory = Path.Combine(Path.GetTempPath(), testName, Path.GetRandomFileName());
+ Directory.CreateDirectory(directory);
+ return directory;
+ }
+
+ private static Project CreateProjectPropertiesOnly(string description)
+ {
+ var project = new Project
+ {
+ Description = description
+ };
+ project.ProjectProperties.Title = "Sync project";
+ return project;
+ }
+
+ private static Project CreateProjectGraph()
+ {
+ var project = new Project();
+
+ var channel = new Channel
+ {
+ Name = "Channel_Main",
+ Description = "Main channel",
+ DeviceDriver = "Simulator"
+ };
+ channel.SetDynamicProperty(Properties.NonUpdatable.ChannelUniqueId, 1001L);
+
+ var device = new Device
+ {
+ Name = "Device_Main",
+ Description = "Main device",
+ Channel = channel,
+ Tags =
+ [
+ CreateTag("Tag_Main", "RAMP", "Primary tag", scalingEnabled: true)
+ ]
+ };
+ device.SetDynamicProperty(Properties.NonUpdatable.DeviceUniqueId, 2001L);
+ device.SetDynamicProperty(Properties.Channel.DeviceDriver, "Simulator");
+ device.SetDynamicProperty(Properties.Device.DeviceDriver, "Simulator");
+ channel.Devices = [device];
+ project.Channels = [channel];
+
+ AssignOwners(project);
+ return project;
+ }
+
+ private static Project CreateStorageProjectGraph()
+ {
+ var project = CreateProjectGraph();
+ project.Description = "Roundtrip project";
+ project.ProjectProperties.Title = "Roundtrip title";
+ return project;
+ }
+
+ private static Tag CreateTag(string name, string address, string description, bool scalingEnabled = false)
+ {
+ var tag = new Tag
+ {
+ Name = name,
+ Description = description,
+ TagAddress = address,
+ DataType = scalingEnabled ? 8 : 1,
+ ReadWriteAccess = scalingEnabled ? 1 : 0,
+ ScanRateMilliseconds = scalingEnabled ? 250 : 100,
+ ScalingType = scalingEnabled ? 1 : 0
+ };
+
+ if (scalingEnabled)
+ {
+ tag.ScalingRawLow = 0;
+ tag.ScalingRawHigh = 100;
+ tag.ScalingScaledLow = 0;
+ tag.ScalingScaledHigh = 1000;
+ tag.ScalingScaledDataType = 8;
+ tag.ScalingClampLow = true;
+ tag.ScalingClampHigh = false;
+ tag.ScalingUnits = "psi";
+ tag.ScalingNegateValue = true;
+ }
+
+ return tag;
+ }
+
+ private static void AssignOwners(Project project)
+ {
+ foreach (var channel in project.Channels ?? [])
+ {
+ foreach (var device in channel.Devices ?? [])
+ {
+ device.Channel = channel;
+
+ foreach (var tag in device.Tags ?? [])
+ {
+ tag.Owner = device;
+ }
+
+ }
+ }
+ }
+
+ private static void AssertProjectsEquivalent(Project expected, Project actual)
+ {
+ actual.Description.ShouldBe(expected.Description);
+ actual.ProjectProperties.Title.ShouldBe(expected.ProjectProperties.Title);
+
+ var channelCompare = EntityCompare.Compare(expected.Channels, actual.Channels);
+ channelCompare.ChangedItems.ShouldBeEmpty();
+ channelCompare.ItemsOnlyInLeft.ShouldBeEmpty();
+ channelCompare.ItemsOnlyInRight.ShouldBeEmpty();
+ channelCompare.UnchangedItems.Count.ShouldBe(expected.Channels?.Count ?? 0);
+
+ foreach (var expectedChannel in expected.Channels ?? [])
+ {
+ var actualChannel = actual.Channels!.Single(channel => channel.Name == expectedChannel.Name);
+ actualChannel.Hash.ShouldBe(expectedChannel.Hash);
+
+ var deviceCompare = EntityCompare.Compare(expectedChannel.Devices, actualChannel.Devices);
+ deviceCompare.ChangedItems.ShouldBeEmpty();
+ deviceCompare.ItemsOnlyInLeft.ShouldBeEmpty();
+ deviceCompare.ItemsOnlyInRight.ShouldBeEmpty();
+ deviceCompare.UnchangedItems.Count.ShouldBe(expectedChannel.Devices?.Count ?? 0);
+
+ foreach (var expectedDevice in expectedChannel.Devices ?? [])
+ {
+ var actualDevice = actualChannel.Devices!.Single(device => device.Name == expectedDevice.Name);
+ actualDevice.Hash.ShouldBe(expectedDevice.Hash);
+
+ var tagCompare = EntityCompare.Compare(expectedDevice.Tags, actualDevice.Tags);
+ tagCompare.ChangedItems.ShouldBeEmpty();
+ tagCompare.ItemsOnlyInLeft.ShouldBeEmpty();
+ tagCompare.ItemsOnlyInRight.ShouldBeEmpty();
+ tagCompare.UnchangedItems.Count.ShouldBe(expectedDevice.Tags?.Count ?? 0);
+
+ (actualDevice.TagGroups?.Count ?? 0).ShouldBe(expectedDevice.TagGroups?.Count ?? 0);
+ }
+ }
+ }
+
+ private static ChangeEvent[] GetQueuedEvents(Kepware.SyncService.SyncService service)
+ {
+ var queueField = typeof(Kepware.SyncService.SyncService).GetField("m_changeQueue", BindingFlags.Instance | BindingFlags.NonPublic);
+ queueField.ShouldNotBeNull();
+
+ var queue = queueField!.GetValue(service) as ConcurrentQueue;
+ queue.ShouldNotBeNull();
+
+ return queue!.ToArray();
+ }
+
+ private sealed class FakeProjectStorage : IProjectStorage
+ {
+ private readonly Project _projectToLoad;
+
+ public FakeProjectStorage(Project projectToLoad)
+ {
+ _projectToLoad = projectToLoad;
+ }
+
+ public int ExportCount { get; private set; }
+
+ public Project? LastExportedProject { get; private set; }
+
+ public Task LoadProject(bool blnLoadFullProject = true, CancellationToken cancellationToken = default)
+ => Task.FromResult(_projectToLoad);
+
+ public async Task ExportProjecAsync(Project project, CancellationToken cancellationToken = default)
+ {
+ ExportCount++;
+ LastExportedProject = await project.CloneAsync(cancellationToken);
+ }
+
+ public IObservable ObserveChanges() => new EmptyObservable();
+ }
+
+ private sealed class EmptyObservable : IObservable
+ {
+ public IDisposable Subscribe(IObserver observer) => new EmptyDisposable();
+ }
+
+ private sealed class EmptyDisposable : IDisposable
+ {
+ public void Dispose()
+ {
+ }
+ }
+ }
+}
+#endif
diff --git a/Kepware.Api.Test/Util/EntityCompareTests.cs b/Kepware.Api.Test/Util/EntityCompareTests.cs
index c0d905b..58c01a6 100644
--- a/Kepware.Api.Test/Util/EntityCompareTests.cs
+++ b/Kepware.Api.Test/Util/EntityCompareTests.cs
@@ -98,6 +98,23 @@ public void Compare_ShouldReturnChanged_WhenEntitiesAreDifferent()
Assert.Equal(EntityCompare.CompareResult.Changed, result);
}
+ [Fact]
+ public void Compare_ShouldReturnChanged_WhenEntityWasMutatedAfterHashWasRead()
+ {
+ // Arrange
+ var sourceEntity = new NamedEntity { Name = "Entity", Description = "Initial" };
+ _ = sourceEntity.Hash;
+ sourceEntity.Description = "Updated";
+
+ var targetEntity = new NamedEntity { Name = "Entity", Description = "Initial" };
+
+ // Act
+ var result = EntityCompare.Compare(sourceEntity, targetEntity);
+
+ // Assert
+ Assert.Equal(EntityCompare.CompareResult.Changed, result);
+ }
+
#endregion
#region Compare - Sammlungen
diff --git a/Kepware.Api/ClientHandler/ProjectApiHandler.cs b/Kepware.Api/ClientHandler/ProjectApiHandler.cs
index c239b94..14c8b05 100644
--- a/Kepware.Api/ClientHandler/ProjectApiHandler.cs
+++ b/Kepware.Api/ClientHandler/ProjectApiHandler.cs
@@ -109,8 +109,9 @@ public async Task CompareAndApplyDetailedAsync(Pro
public async Task CompareAndApplyDetailedAsync(Project sourceProject, Project projectFromApi, CancellationToken cancellationToken = default)
{
var result = new ProjectCompareAndApplyResult();
+ var projectPropertyDiff = sourceProject.GetUpdateDiff(projectFromApi);
- if (sourceProject.Hash != projectFromApi.Hash)
+ if (projectPropertyDiff.Count > 0)
{
m_logger.LogInformation("Project properties has changed. Updating project properties...");
var projectPropertyFailure = await SetProjectPropertiesDetailedAsync(sourceProject, cancellationToken: cancellationToken).ConfigureAwait(false);
diff --git a/Kepware.Api/Model/BaseEntity.cs b/Kepware.Api/Model/BaseEntity.cs
index 7a302a8..ad9f295 100644
--- a/Kepware.Api/Model/BaseEntity.cs
+++ b/Kepware.Api/Model/BaseEntity.cs
@@ -45,6 +45,9 @@ public abstract class BaseEntity : IEquatable
{
private bool _dynamicPropertiesNormalized = false;
private ulong? _hash;
+ private long? _projectId;
+ private string? _description = string.Empty;
+ private Dictionary _dynamicProperties = [];
///
/// Flag indicating if the entity includes nested dynamic properties that require normalization.
@@ -68,14 +71,36 @@ public abstract class BaseEntity : IEquatable
[JsonPropertyName(Properties.ProjectId)]
[YamlIgnore]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public long? ProjectId { get; set; } = null;
+ public long? ProjectId
+ {
+ get => _projectId;
+ set
+ {
+ if (_projectId != value)
+ {
+ _projectId = value;
+ InvalidateHash();
+ }
+ }
+ }
///
/// The description of the entity.
///
[JsonPropertyName(Properties.Description)]
[YamlMember(Alias = Properties.Description)]
- public string? Description { get; set; } = string.Empty;
+ public string? Description
+ {
+ get => _description;
+ set
+ {
+ if (_description != value)
+ {
+ _description = value;
+ InvalidateHash();
+ }
+ }
+ }
///
/// The type name of the entity.
@@ -88,7 +113,16 @@ public abstract class BaseEntity : IEquatable
/// Dynamic properties associated with the entity.
///
[JsonExtensionData]
- public Dictionary DynamicProperties { get; set; } = new();
+ public Dictionary DynamicProperties
+ {
+ get => _dynamicProperties;
+ set
+ {
+ _dynamicProperties = value ?? [];
+ _dynamicPropertiesNormalized = false;
+ InvalidateHash();
+ }
+ }
///
/// Compares the current entity with another for equality.
@@ -161,18 +195,23 @@ public BaseEntity SetDynamicProperty(string key, T value)
if (DynamicProperties.ContainsKey(key))
{
DynamicProperties.Remove(key);
- _hash = null;
+ InvalidateHash();
}
}
else
{
DynamicProperties[key] = value is JsonElement jsonElement ? jsonElement : KepJsonContext.WrapInJsonElement(value);
- _hash = null;
+ InvalidateHash();
}
return this;
}
+ ///
+ /// Invalidates the cached hash value.
+ ///
+ protected void InvalidateHash() => _hash = null;
+
///
/// Attempts to retrieve a dynamic property by key.
///
@@ -200,6 +239,8 @@ public bool TryGetDynamicProperty(string key, [NotNullWhen(true)] out T? valu
/// The calculated hash.
protected internal virtual ulong CalculateHash()
{
+ if (IncludesNestedDynamicProperties) EnsureDynamicPropertiesNormalized();
+
return CustomHashGenerator.ComputeHash(
KepJsonContext.Unwrap(DynamicProperties.Except(Properties.NonSerialized.AsHashSet, Properties.NonUpdatable.AsHashSet, ConditionalNonSerialized()))
.Concat(
@@ -222,6 +263,7 @@ protected virtual void EnsureDynamicPropertiesNormalized()
NormalizeNestedProperties();
_dynamicPropertiesNormalized = true;
+ InvalidateHash();
}
///
@@ -293,6 +335,9 @@ public virtual async Task Cleanup(IKepwareDefaultValueProvider defaultValueProvi
public virtual Dictionary GetUpdateDiff(DefaultEntity other)
{
+ if (IncludesNestedDynamicProperties) EnsureDynamicPropertiesNormalized();
+ if (other.IncludesNestedDynamicProperties) other.EnsureDynamicPropertiesNormalized();
+
var diff = new Dictionary();
if (Description != other.Description)
@@ -332,12 +377,25 @@ public class DefaultEntity : BaseEntity, IHaveOwner
[DebuggerDisplay("{TypeName} - {Name} - {Description}")]
public class NamedEntity : DefaultEntity, IHaveName
{
+ private string _name = string.Empty;
+
///
/// The name of the entity.
///
[JsonPropertyName(Properties.Name)]
[YamlIgnore]
- public string Name { get; set; } = string.Empty;
+ public string Name
+ {
+ get => _name;
+ set
+ {
+ if (_name != value)
+ {
+ _name = value;
+ InvalidateHash();
+ }
+ }
+ }
public NamedEntity()
{
@@ -401,7 +459,13 @@ public abstract class NamedUidEntity : NamedEntity
///
/// Removes the unique ID from the entity.
///
- public void RemoveUniqueId() => DynamicProperties.Remove(UniqueIdKey);
+ public void RemoveUniqueId()
+ {
+ if (DynamicProperties.Remove(UniqueIdKey))
+ {
+ InvalidateHash();
+ }
+ }
protected NamedUidEntity()
{
diff --git a/KepwareSync.Service/Kepware.SyncService.csproj b/KepwareSync.Service/Kepware.SyncService.csproj
index 8662113..594ba77 100644
--- a/KepwareSync.Service/Kepware.SyncService.csproj
+++ b/KepwareSync.Service/Kepware.SyncService.csproj
@@ -26,6 +26,10 @@
+
+
+
+