using Shouldly; using Xunit; 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; /// /// v3 WP1: the namespace-binding + Galaxy-FullName rules are retired (Namespace entity gone; /// Tags are raw-only, not equipment-bound). The retained rules (UNS segments, path length, /// EquipmentUuid immutability, external-id reservation preflight, EquipmentId derivation, /// cluster topology, VirtualTag equipment-signal collision) are exercised below against the v3 /// entity shapes. WP4 (Wave C) adds the new v3 rules (raw-name charset, historized-tagname length, /// UNS effective-leaf uniqueness). /// [Trait("Category", "Unit")] public sealed class DraftValidatorTests { /// Verifies that UnsSegment validation rejects uppercase and special characters. /// The segment name to validate. /// Whether the validation should pass for this name. [Theory] [InlineData("valid-name", true)] [InlineData("line-01", true)] [InlineData("_default", true)] [InlineData("UPPER", false)] [InlineData("with space", false)] [InlineData("", false)] public void UnsSegment_rule_accepts_lowercase_or_default_only(string name, bool shouldPass) { var uuid = Guid.NewGuid(); var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Equipment = [ new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = name, UnsLineId = "line-a", MachineCode = "m", }, ], }; var errors = DraftValidator.Validate(draft); var hasUnsError = errors.Any(e => e.Code == "UnsSegmentInvalid"); hasUnsError.ShouldBe(!shouldPass); } /// Verifies that equipment UUID must remain immutable across generations. [Fact] public void EquipmentUuid_change_across_generations_is_rejected() { var oldUuid = Guid.Parse("11111111-1111-1111-1111-111111111111"); var newUuid = Guid.Parse("22222222-2222-2222-2222-222222222222"); var eid = DraftValidator.DeriveEquipmentId(oldUuid); var draft = new DraftSnapshot { GenerationId = 2, ClusterId = "c", Equipment = [new Equipment { EquipmentUuid = newUuid, EquipmentId = eid, Name = "eq", UnsLineId = "line-a", MachineCode = "m" }], PriorEquipment = [new Equipment { EquipmentUuid = oldUuid, EquipmentId = eid, Name = "eq", UnsLineId = "line-a", MachineCode = "m" }], }; DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentUuidImmutable"); } /// Verifies that a ZTag cannot be reserved by a different equipment UUID. [Fact] public void ZTag_reserved_by_different_uuid_is_rejected() { var uuid = Guid.NewGuid(); var otherUuid = Guid.NewGuid(); var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = "eq", UnsLineId = "line-a", MachineCode = "m", ZTag = "ZT-001" }], ActiveReservations = [new ExternalIdReservation { Kind = ReservationKind.ZTag, Value = "ZT-001", EquipmentUuid = otherUuid, ClusterId = "c", FirstPublishedBy = "t" }], }; DraftValidator.Validate(draft).ShouldContain(e => e.Code == "BadDuplicateExternalIdentifier"); } /// Verifies that equipment ID must be derived from its UUID. [Fact] public void EquipmentId_that_does_not_match_derivation_is_rejected() { var uuid = Guid.NewGuid(); var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-operator-typed", Name = "eq", UnsLineId = "line-a", MachineCode = "m" }], }; DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived"); } /// Verifies that all violations are reported simultaneously (single-pass validation). [Fact] public void Draft_with_multiple_violations_surfaces_all_of_them() { var uuid = Guid.NewGuid(); var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", // EQ-wrong is not the canonical derivation, and "BAD NAME" fails the UNS segment regex. Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-wrong", Name = "BAD NAME", UnsLineId = "line-a", MachineCode = "m" }], }; var errors = DraftValidator.Validate(draft); errors.ShouldContain(e => e.Code == "EquipmentIdNotDerived"); errors.ShouldContain(e => e.Code == "UnsSegmentInvalid"); } /// Probe for the deploy-path gate: a full, realistic v3 config (UNS area/line + /// canonical Equipment + distinct-named VirtualTags) must produce ZERO validation errors so the /// reject-on-any-error deploy gate is safe to activate. [Fact] public void Full_realistic_config_passes_all_rules() { var area = new UnsArea { UnsAreaId = "area-filling", ClusterId = "MAIN", Name = "filling" }; var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = area.UnsAreaId, Name = "line-1" }; var rinserUuid = Guid.NewGuid(); var fillerUuid = Guid.NewGuid(); var rinser = new Equipment { EquipmentUuid = rinserUuid, EquipmentId = DraftValidator.DeriveEquipmentId(rinserUuid), Name = "rinser-01", UnsLineId = line.UnsLineId, MachineCode = "machine_001", }; var filler = new Equipment { EquipmentUuid = fillerUuid, EquipmentId = DraftValidator.DeriveEquipmentId(fillerUuid), Name = "filler-02", UnsLineId = line.UnsLineId, MachineCode = "machine_002", }; var draft = new DraftSnapshot { GenerationId = 0, ClusterId = string.Empty, // global snapshot — matches DraftSnapshotFactory.FromConfigDbAsync UnsAreas = [area], UnsLines = [line], Equipment = [rinser, filler], VirtualTags = [ BuildVirtualTag(equipmentId: rinser.EquipmentId, name: "oee"), BuildVirtualTag(equipmentId: filler.EquipmentId, name: "throughput"), ], }; var errors = DraftValidator.Validate(draft); errors.ShouldBeEmpty( "a realistic canonical deployed config must pass every DraftValidator rule so the " + "reject-on-any-error deploy gate is safe; firing rules: " + string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}"))); } // ------------------------------------------------------------------------------------ // ValidateNoUnsEffectiveNameCollision — v3: VirtualTag name uniqueness within equipment // ------------------------------------------------------------------------------------ /// Two VirtualTags sharing (EquipmentId, Name) collide on a single OPC UA NodeId. [Fact] public void Two_VirtualTags_same_equipment_and_name_collide() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", VirtualTags = [ BuildVirtualTag(equipmentId: "eq-1", name: "speed", suffix: "a"), BuildVirtualTag(equipmentId: "eq-1", name: "speed", suffix: "b"), ], }; DraftValidator.Validate(draft).ShouldContain(e => e.Code == "UnsEffectiveNameCollision"); } /// VirtualTags with the same name under DIFFERENT equipment do not collide. [Fact] public void Same_name_different_equipment_does_not_collide() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", VirtualTags = [ BuildVirtualTag(equipmentId: "eq-1", name: "speed"), BuildVirtualTag(equipmentId: "eq-2", name: "speed"), ], }; DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision"); } /// The rename-induced case the authoring guard never sees: an authoring-clean draft where /// a reference (no override, effective name = backing raw tag's current Name) ends up sharing an /// effective name with a VirtualTag after a raw-tag rename. The deploy gate is the backstop. [Fact] public void Reference_effective_from_raw_name_collides_with_virtualtag_after_rename() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Tags = [BuildTag(tagId: "tag-1", name: "speed")], // raw tag renamed to "speed" UnsTagReferences = [ // No override → effective name is the backing raw tag's Name ("speed"). BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null), ], VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")], }; var errors = DraftValidator.Validate(draft); errors.ShouldContain(e => e.Code == "UnsEffectiveNameCollision"); // Names both colliding sources + the equipment. var msg = errors.First(e => e.Code == "UnsEffectiveNameCollision").Message; msg.ShouldContain("ref-1"); msg.ShouldContain("vtag-eq-1-speed"); msg.ShouldContain("eq-1"); } /// A reference's DisplayNameOverride (not its backing raw name) is the effective name and /// must be unique against a VirtualTag in the same equipment. [Fact] public void Reference_override_collides_with_virtualtag() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Tags = [BuildTag(tagId: "tag-1", name: "raw-name")], // backing raw name differs UnsTagReferences = [ BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: "computed"), ], VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "computed")], }; DraftValidator.Validate(draft).ShouldContain(e => e.Code == "UnsEffectiveNameCollision"); } /// Effective-name comparison is ordinal: "Speed" (VirtualTag) and "speed" (reference's /// backing raw name) do NOT collide — they are distinct OPC UA browse names. [Fact] public void Effective_name_collision_is_ordinal_case_sensitive() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Tags = [BuildTag(tagId: "tag-1", name: "speed")], UnsTagReferences = [ BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null), ], VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "Speed")], }; DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision"); } private static VirtualTag BuildVirtualTag(string equipmentId, string name, string suffix = "") => new() { VirtualTagId = $"vtag-{equipmentId}-{name}{suffix}", EquipmentId = equipmentId, Name = name, DataType = "Float", ScriptId = "s-1", }; private static Tag BuildTag(string tagId, string name) => new() { TagId = tagId, DeviceId = "dev-1", Name = name, DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }; private static UnsTagReference BuildReference(string refId, string equipmentId, string tagId, string? overrideName) => new() { UnsTagReferenceId = refId, EquipmentId = equipmentId, TagId = tagId, DisplayNameOverride = overrideName, }; // ------------------------------------------------------------------------------------ // ValidateEquipReferenceResolution — v3 WP4: {{equip}}/ must resolve to a reference // ------------------------------------------------------------------------------------ private static Script BuildScript(string id, string source) => new() { ScriptId = id, Name = id, SourceCode = source, SourceHash = $"h-{id}", }; private static Tag BuildRawTag(string tagId, string name) => new() { TagId = tagId, DeviceId = "dev-1", Name = name, DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }; private static UnsTagReference BuildReference(string id, string equipmentId, string tagId, string? overrideName = null) => new() { UnsTagReferenceId = id, EquipmentId = equipmentId, TagId = tagId, DisplayNameOverride = overrideName, }; /// A VirtualTag script using {{equip}}/Speed with no reference "Speed" on the equipment is /// a deploy error naming the equipment + the missing ref. [Fact] public void VirtualTag_equip_ref_unresolved_is_deploy_error() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")], VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")], }; var err = DraftValidator.Validate(draft).First(e => e.Code == "EquipReferenceUnresolved"); err.Message.ShouldContain("eq-1"); err.Message.ShouldContain("Speed"); } /// The same script resolves cleanly when the equipment has a reference whose effective name is /// "Speed" (backing raw tag's Name). [Fact] public void VirtualTag_equip_ref_resolved_is_clean() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")], VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")], Tags = [BuildRawTag("tag-1", "Speed")], UnsTagReferences = [BuildReference("ref-1", "eq-1", "tag-1")], }; DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipReferenceUnresolved"); } /// A DisplayNameOverride is the effective name: {{equip}}/MotorSpeed resolves to a /// reference overridden to "MotorSpeed" even though the backing raw tag's Name is "raw_speed". [Fact] public void VirtualTag_equip_ref_resolves_by_override_name() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/MotorSpeed\").Value;")], VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")], Tags = [BuildRawTag("tag-1", "raw_speed")], UnsTagReferences = [BuildReference("ref-1", "eq-1", "tag-1", overrideName: "MotorSpeed")], }; DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipReferenceUnresolved"); } /// A reference on a DIFFERENT equipment does not satisfy the token — resolution is per owning /// equipment. [Fact] public void VirtualTag_equip_ref_does_not_resolve_across_equipment() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")], VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")], Tags = [BuildRawTag("tag-1", "Speed")], UnsTagReferences = [BuildReference("ref-1", "eq-2", "tag-1")], // reference is on eq-2, not eq-1 }; DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipReferenceUnresolved"); } /// Alarm message-template {{equip}}/Temp follows the same rule — an unresolved ref in the /// template is a deploy error. [Fact] public void ScriptedAlarm_message_template_equip_ref_unresolved_is_deploy_error() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Scripts = [BuildScript("s-1", "return true;")], ScriptedAlarms = [ new ScriptedAlarm { ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "overheat", AlarmType = "LimitAlarm", MessageTemplate = "Too hot: {{equip}}/Temp", PredicateScriptId = "s-1", }, ], }; DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipReferenceUnresolved"); } // ------------------------------------------------------------------------------------ // Phase 6.3 task #148 part 2 — ValidateClusterTopology (unchanged in v3) // ------------------------------------------------------------------------------------ /// Verifies that cluster topology validation checks node count against declared redundancy mode. /// The declared cluster node count. /// The declared redundancy mode. /// The number of enabled nodes to include in the topology. /// The number of ClusterRedundancyModeInvalid errors expected. [Theory] [InlineData(1, RedundancyMode.None, 1, 0)] // single-node standalone — ok [InlineData(2, RedundancyMode.Warm, 2, 0)] // 2-node warm — ok [InlineData(2, RedundancyMode.Hot, 2, 0)] // 2-node hot — ok [InlineData(1, RedundancyMode.Warm, 1, 1)] // declared mismatch — should flag [InlineData(2, RedundancyMode.None, 2, 1)] // None with 2 nodes — should flag public void ValidateClusterTopology_checks_declared_pair( byte nodeCount, RedundancyMode mode, int enabledNodes, int expectedDeclaredErrors) { var cluster = BuildCluster(nodeCount: nodeCount, mode: mode); var nodes = Enumerable.Range(0, enabledNodes) .Select(i => BuildNode($"n-{i}", enabled: true)) .ToList(); var errors = DraftValidator.ValidateClusterTopology(cluster, nodes); errors.Count(e => e.Code == "ClusterRedundancyModeInvalid").ShouldBe(expectedDeclaredErrors); } /// Verifies that disabled nodes cause topology validation to fail. [Fact] public void ValidateClusterTopology_flags_disabled_node_mismatch() { var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot); var nodes = new[] { BuildNode("primary", enabled: true), BuildNode("backup", enabled: false), }; var errors = DraftValidator.ValidateClusterTopology(cluster, nodes); errors.ShouldContain(e => e.Code == "ClusterEnabledNodeCountMismatch"); } /// Verifies that a valid standalone cluster passes validation. [Fact] public void ValidateClusterTopology_returns_no_errors_on_valid_standalone() { var cluster = BuildCluster(nodeCount: 1, mode: RedundancyMode.None); var nodes = new[] { BuildNode("only", enabled: true) }; var errors = DraftValidator.ValidateClusterTopology(cluster, nodes); errors.ShouldBeEmpty(); } private static ServerCluster BuildCluster(byte nodeCount, RedundancyMode mode) => new() { ClusterId = "c-test", Name = "Test", Enterprise = "zb", Site = "dev", NodeCount = nodeCount, RedundancyMode = mode, Enabled = true, CreatedBy = "t", }; private static ClusterNode BuildNode(string id, bool enabled) => new() { NodeId = id, ClusterId = "c-test", Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001, ApplicationUri = $"urn:{id}", ServiceLevelBase = 200, Enabled = enabled, CreatedBy = "t", }; // ------------------------------------------------------------------------------------ // ValidatePathLength — Enterprise/Site length precision (Configuration-003) // ------------------------------------------------------------------------------------ /// Verifies that path length validation uses actual Enterprise and Site lengths. [Fact] public void PathLength_uses_actual_Enterprise_Site_when_provided() { var areaId = "area-a"; var lineId = "line-b"; var uuid = Guid.NewGuid(); var eqName = new string('x', 90); // 90 chars — exceeds UNS regex but that's a separate error var snapshot = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Enterprise = "zb", // 2 chars (actual) Site = "s", // 1 char (actual) UnsAreas = [new UnsArea { UnsAreaId = areaId, ClusterId = "c", Name = new string('a', 32) }], UnsLines = [new UnsLine { UnsLineId = lineId, UnsAreaId = areaId, Name = new string('b', 32) }], Equipment = [ new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = eqName, UnsLineId = lineId, MachineCode = "m", }, ], }; var errors = DraftValidator.Validate(snapshot); errors.ShouldNotContain(e => e.Code == "PathTooLong", "actual Enterprise='zb' + Site='s' keeps total path at 161 chars — under the 200-char limit"); } /// Verifies that path length validation uses conservative fallback when Enterprise and Site are absent. [Fact] public void PathLength_conservative_fallback_when_Enterprise_Site_absent() { var areaId = "area-x"; var lineId = "line-y"; var uuid = Guid.NewGuid(); var snapshot = new DraftSnapshot { GenerationId = 1, ClusterId = "c", UnsAreas = [new UnsArea { UnsAreaId = areaId, ClusterId = "c", Name = new string('a', 32) }], UnsLines = [new UnsLine { UnsLineId = lineId, UnsAreaId = areaId, Name = new string('b', 32) }], Equipment = [ new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = new string('c', 29), UnsLineId = lineId, MachineCode = "m", }, ], }; var errors = DraftValidator.Validate(snapshot); errors.ShouldNotContain(e => e.Code == "PathTooLong", "conservative 32+32+32+32+29+4 = 161 chars is still under the 200-char limit"); } }