From 4365c8ed652f7d5104da4a8825932f33d8321c2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 06:15:33 +0000 Subject: [PATCH 1/5] Initial plan From 9150740ada640df1049c4695bf3667af7668b7fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 06:33:15 +0000 Subject: [PATCH 2/5] test: add sync idempotency regression coverage Agent-Logs-Url: https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/sessions/86ad7ece-bec0-429d-a50a-3d4ff8092bde Co-authored-by: BoBiene <23037659+BoBiene@users.noreply.github.com> --- Kepware.Api.Test/Kepware.Api.Test.csproj | 5 + .../Serializer/RoundtripIdempotencyTests.cs | 214 ++++++++++ Kepware.Api.Test/Sync/SyncIdempotencyTests.cs | 390 ++++++++++++++++++ .../Kepware.SyncService.csproj | 4 + 4 files changed, 613 insertions(+) create mode 100644 Kepware.Api.Test/Serializer/RoundtripIdempotencyTests.cs create mode 100644 Kepware.Api.Test/Sync/SyncIdempotencyTests.cs 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..f234480 --- /dev/null +++ b/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs @@ -0,0 +1,390 @@ +#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 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/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 @@ + + + + From 9c04cd173cdcbbc7a4ccc5227b50a8a8ce83d790 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:25:34 +0000 Subject: [PATCH 3/5] test: add stale hash regression coverage Agent-Logs-Url: https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/sessions/2878be68-07b9-4a1d-aaf9-3e5d60bebeb2 Co-authored-by: BoBiene <23037659+BoBiene@users.noreply.github.com> --- Kepware.Api.Test/Sync/SyncIdempotencyTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs b/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs index f234480..04f337d 100644 --- a/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs +++ b/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs @@ -117,6 +117,32 @@ public async Task CompareAndApply_RoundtripReloadedProject_SecondRun_ShouldRepor } } + [Fact] + public async Task CompareAndApply_ProjectHashMismatchWithoutProjectPropertyDiff_ShouldReportNoChanges() + { + ConfigureConnectedClient(); + + var sourceProject = CreateProjectPropertiesOnly("Desired description"); + _ = sourceProject.Hash; + sourceProject.Channels = [CreateTestChannel("Channel_Main", "Simulator")]; + + 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() { From 5bd842dca2b290b9b1efdb5bf9eca8b8f23639dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:36:21 +0000 Subject: [PATCH 4/5] fix: address stale hash comparisons Agent-Logs-Url: https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/sessions/5052719e-3481-45f1-807f-a4a9760b84f4 Co-authored-by: BoBiene <23037659+BoBiene@users.noreply.github.com> --- Kepware.Api.Test/Util/EntityCompareTests.cs | 17 +++++++++++++++++ Kepware.Api/ClientHandler/ProjectApiHandler.cs | 3 ++- Kepware.Api/Model/BaseEntity.cs | 5 +---- 3 files changed, 20 insertions(+), 5 deletions(-) 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..2af911b 100644 --- a/Kepware.Api/Model/BaseEntity.cs +++ b/Kepware.Api/Model/BaseEntity.cs @@ -44,7 +44,6 @@ public interface IHaveName public abstract class BaseEntity : IEquatable { private bool _dynamicPropertiesNormalized = false; - private ulong? _hash; /// /// Flag indicating if the entity includes nested dynamic properties that require normalization. @@ -60,7 +59,7 @@ public abstract class BaseEntity : IEquatable /// [JsonIgnore] [YamlIgnore] - public ulong Hash => _hash ??= CalculateHash(); + public ulong Hash => CalculateHash(); /// /// The project ID the entity belongs to. @@ -161,13 +160,11 @@ public BaseEntity SetDynamicProperty(string key, T value) if (DynamicProperties.ContainsKey(key)) { DynamicProperties.Remove(key); - _hash = null; } } else { DynamicProperties[key] = value is JsonElement jsonElement ? jsonElement : KepJsonContext.WrapInJsonElement(value); - _hash = null; } return this; From 603d0d6fae31fbebc838a3d6158742c358666a9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:39:25 +0000 Subject: [PATCH 5/5] fix: preserve hash cache with invalidation Agent-Logs-Url: https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/sessions/5052719e-3481-45f1-807f-a4a9760b84f4 Co-authored-by: BoBiene <23037659+BoBiene@users.noreply.github.com> --- Kepware.Api.Test/Sync/SyncIdempotencyTests.cs | 5 +- Kepware.Api/Model/BaseEntity.cs | 79 +++++++++++++++++-- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs b/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs index 04f337d..806be00 100644 --- a/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs +++ b/Kepware.Api.Test/Sync/SyncIdempotencyTests.cs @@ -122,9 +122,10 @@ public async Task CompareAndApply_ProjectHashMismatchWithoutProjectPropertyDiff_ { ConfigureConnectedClient(); - var sourceProject = CreateProjectPropertiesOnly("Desired description"); + var sourceProject = CreateProjectPropertiesOnly(string.Empty); + sourceProject.Description = "Transient description"; _ = sourceProject.Hash; - sourceProject.Channels = [CreateTestChannel("Channel_Main", "Simulator")]; + sourceProject.Description = string.Empty; var targetProject = await sourceProject.CloneAsync(); AssignOwners(targetProject); diff --git a/Kepware.Api/Model/BaseEntity.cs b/Kepware.Api/Model/BaseEntity.cs index 2af911b..ad9f295 100644 --- a/Kepware.Api/Model/BaseEntity.cs +++ b/Kepware.Api/Model/BaseEntity.cs @@ -44,6 +44,10 @@ public interface IHaveName 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. @@ -59,7 +63,7 @@ public abstract class BaseEntity : IEquatable /// [JsonIgnore] [YamlIgnore] - public ulong Hash => CalculateHash(); + public ulong Hash => _hash ??= CalculateHash(); /// /// The project ID the entity belongs to. @@ -67,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. @@ -87,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. @@ -160,16 +195,23 @@ public BaseEntity SetDynamicProperty(string key, T value) if (DynamicProperties.ContainsKey(key)) { DynamicProperties.Remove(key); + InvalidateHash(); } } else { DynamicProperties[key] = value is JsonElement jsonElement ? jsonElement : KepJsonContext.WrapInJsonElement(value); + InvalidateHash(); } return this; } + /// + /// Invalidates the cached hash value. + /// + protected void InvalidateHash() => _hash = null; + /// /// Attempts to retrieve a dynamic property by key. /// @@ -197,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( @@ -219,6 +263,7 @@ protected virtual void EnsureDynamicPropertiesNormalized() NormalizeNestedProperties(); _dynamicPropertiesNormalized = true; + InvalidateHash(); } /// @@ -290,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) @@ -329,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() { @@ -398,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() {