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")); } // ------------------------------------------------------------------------------------ // Configuration-011 — pin the documented ordering behaviour: a thrown Removed callback // records an entity error but the applier still runs the Added/Modified passes (the // current contract — see GenerationApplier comment about cascades settling). // ------------------------------------------------------------------------------------ [Fact] public async Task Apply_continues_to_Added_pass_when_a_Removed_callback_throws() { var callLog = new List(); var applier = new GenerationApplier(new ApplyCallbacks { OnTag = (c, _) => { callLog.Add($"tag:{c.Kind}:{c.LogicalId}"); if (c.Kind == ChangeKind.Removed) throw new InvalidOperationException("removed-failed"); return Task.CompletedTask; }, }); var from = SnapshotWith(tags: [Tag("tag-old", "X")]); var to = SnapshotWith(tags: [Tag("tag-new", "Y")]); var result = await applier.ApplyAsync(from, to, CancellationToken.None); result.Succeeded.ShouldBeFalse(); result.Errors.ShouldContain(e => e.Contains("tag-old") && e.Contains("removed-failed")); // The Added pass still runs even though Removed failed. callLog.ShouldContain("tag:Removed:tag-old"); callLog.ShouldContain("tag:Added:tag-new"); } // ------------------------------------------------------------------------------------ // Configuration-007 — ApplyPass must propagate OperationCanceledException rather than // recording it as an entity error. Cancellation between passes must also halt the apply. // ------------------------------------------------------------------------------------ [Fact] public async Task Apply_propagates_OperationCanceledException_from_callback_when_token_cancelled() { // A callback that observes a cancelled token and throws OperationCanceledException // must abort the entire apply, not be silently swallowed and recorded as an error. using var cts = new CancellationTokenSource(); var applier = new GenerationApplier(new ApplyCallbacks { OnTag = (c, ct) => { cts.Cancel(); ct.ThrowIfCancellationRequested(); return Task.CompletedTask; }, }); var to = SnapshotWith(tags: [Tag("tag-1", "A")]); await Should.ThrowAsync(async () => await applier.ApplyAsync(from: null, to, cts.Token)); } [Fact] public async Task Apply_stops_between_passes_when_cancellation_requested() { // After a Removed pass completes, the applier should observe cancellation before // running the Added/Modified passes — not silently keep walking. var callLog = new List(); using var cts = new CancellationTokenSource(); var applier = new GenerationApplier(new ApplyCallbacks { OnTag = (c, _) => { callLog.Add($"tag:{c.Kind}:{c.LogicalId}"); // Cancel after the Removed pass finishes — before the Added pass runs. if (c.Kind == ChangeKind.Removed) cts.Cancel(); return Task.CompletedTask; }, }); // `from` has tag-1, `to` has tag-2 — produces one Removed + one Added. var from = SnapshotWith(tags: [Tag("tag-1", "A")]); var to = SnapshotWith(tags: [Tag("tag-2", "B")]); await Should.ThrowAsync(async () => await applier.ApplyAsync(from, to, cts.Token)); callLog.ShouldContain("tag:Removed:tag-1"); callLog.ShouldNotContain("tag:Added:tag-2", "Added pass must not run after cancellation observed between passes"); } }