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 @@ + + + +