using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Apply; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; [Trait("Category", "Unit")] public sealed class GenerationApplierTests { private static DraftSnapshot SnapshotWith( IReadOnlyList? drivers = null, IReadOnlyList? equipment = null, IReadOnlyList? tags = null) => new() { GenerationId = 1, ClusterId = "c", DriverInstances = drivers ?? [], Equipment = equipment ?? [], Tags = tags ?? [], }; private static DriverInstance Driver(string id) => new() { DriverInstanceId = id, ClusterId = "c", NamespaceId = "ns", Name = id, DriverType = "ModbusTcp", DriverConfig = "{}" }; private static Equipment Eq(string id, Guid uuid) => new() { EquipmentUuid = uuid, EquipmentId = id, DriverInstanceId = "d", UnsLineId = "line-a", Name = id, MachineCode = id }; private static Tag Tag(string id, string name) => new() { TagId = id, DriverInstanceId = "d", Name = name, FolderPath = "/a", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" }; [Fact] public void Diff_from_empty_to_one_driver_five_equipment_fifty_tags_is_all_Added() { var uuid = (int i) => Guid.Parse($"00000000-0000-0000-0000-{i:000000000000}"); var equipment = Enumerable.Range(1, 5).Select(i => Eq($"eq-{i}", uuid(i))).ToList(); var tags = Enumerable.Range(1, 50).Select(i => Tag($"tag-{i}", $"T{i}")).ToList(); var diff = GenerationDiffer.Compute(from: null, to: SnapshotWith(drivers: [Driver("d-1")], equipment: equipment, tags: tags)); diff.Drivers.Count.ShouldBe(1); diff.Drivers.ShouldAllBe(c => c.Kind == ChangeKind.Added); diff.Equipment.Count.ShouldBe(5); diff.Equipment.ShouldAllBe(c => c.Kind == ChangeKind.Added); diff.Tags.Count.ShouldBe(50); diff.Tags.ShouldAllBe(c => c.Kind == ChangeKind.Added); } [Fact] public void Diff_flags_single_tag_name_change_as_Modified_only_for_that_tag() { var before = SnapshotWith(tags: [Tag("tag-1", "Old"), Tag("tag-2", "Keep")]); var after = SnapshotWith(tags: [Tag("tag-1", "New"), Tag("tag-2", "Keep")]); var diff = GenerationDiffer.Compute(before, after); diff.Tags.Count.ShouldBe(1); diff.Tags[0].Kind.ShouldBe(ChangeKind.Modified); diff.Tags[0].LogicalId.ShouldBe("tag-1"); } [Fact] public void Diff_flags_Removed_equipment_and_its_tags() { var uuid1 = Guid.NewGuid(); var before = SnapshotWith( equipment: [Eq("eq-1", uuid1), Eq("eq-2", Guid.NewGuid())], tags: [Tag("tag-1", "A"), Tag("tag-2", "B")]); var after = SnapshotWith( equipment: [Eq("eq-2", before.Equipment[1].EquipmentUuid)], tags: [Tag("tag-2", "B")]); var diff = GenerationDiffer.Compute(before, after); diff.Equipment.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "eq-1"); diff.Tags.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "tag-1"); } [Fact] public async Task Apply_dispatches_callbacks_in_dependency_order_and_survives_idempotent_retry() { var callLog = new List(); var applier = new GenerationApplier(new ApplyCallbacks { OnDriver = (c, _) => { callLog.Add($"drv:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; }, OnEquipment = (c, _) => { callLog.Add($"eq:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; }, OnTag = (c, _) => { callLog.Add($"tag:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; }, }); var to = SnapshotWith( drivers: [Driver("d-1")], equipment: [Eq("eq-1", Guid.NewGuid())], tags: [Tag("tag-1", "A")]); var result1 = await applier.ApplyAsync(from: null, to, CancellationToken.None); result1.Succeeded.ShouldBeTrue(); // Driver Added must come before Equipment Added must come before Tag Added var drvIdx = callLog.FindIndex(s => s.StartsWith("drv:Added")); var eqIdx = callLog.FindIndex(s => s.StartsWith("eq:Added")); var tagIdx = callLog.FindIndex(s => s.StartsWith("tag:Added")); drvIdx.ShouldBeLessThan(eqIdx); eqIdx.ShouldBeLessThan(tagIdx); // Idempotent retry: re-applying the same diff must not blow up var countBefore = callLog.Count; var result2 = await applier.ApplyAsync(from: null, to, CancellationToken.None); result2.Succeeded.ShouldBeTrue(); callLog.Count.ShouldBe(countBefore * 2); } [Fact] public async Task Apply_collects_errors_from_failing_callback_without_aborting() { var applier = new GenerationApplier(new ApplyCallbacks { OnTag = (c, _) => c.LogicalId == "tag-bad" ? throw new InvalidOperationException("simulated") : Task.CompletedTask, }); var to = SnapshotWith(tags: [Tag("tag-ok", "A"), Tag("tag-bad", "B")]); var result = await applier.ApplyAsync(from: null, to, CancellationToken.None); result.Succeeded.ShouldBeFalse(); result.Errors.ShouldContain(e => e.Contains("tag-bad") && e.Contains("simulated")); } }