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; [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, DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m", }, ], }; var errors = DraftValidator.Validate(draft); var hasUnsError = errors.Any(e => e.Code == "UnsSegmentInvalid"); hasUnsError.ShouldBe(!shouldPass); } /// Verifies that namespace cannot be bound to a different cluster. [Fact] public void Cross_cluster_namespace_binding_is_rejected() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c-A", Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }], DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }], }; var errors = DraftValidator.Validate(draft); errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding"); } /// Verifies that namespace in the same cluster is accepted. [Fact] public void Same_cluster_namespace_binding_is_accepted() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c-A", Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-A", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }], DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }], }; DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "BadCrossClusterNamespaceBinding"); } /// 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", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }], PriorEquipment = [new Equipment { EquipmentUuid = oldUuid, EquipmentId = eid, Name = "eq", DriverInstanceId = "d", 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", DriverInstanceId = "d", 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", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }], }; DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived"); } /// Verifies that the canonical Galaxy driver type (GalaxyMxGateway, per PR 7.2 — /// it was "Galaxy" pre-PR-7.2) is a standard Equipment-kind driver: binding it to an /// Equipment namespace produces no kind-mismatch error. [Fact] public void GalaxyMxGateway_driver_in_Equipment_namespace_is_allowed() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }], DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c", NamespaceId = "ns-1", Name = "drv", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }], }; DraftValidator.Validate(draft).ShouldBeEmpty(); } /// Verifies that all validation errors are reported simultaneously. [Fact] public void Draft_with_three_violations_surfaces_all_three() { var uuid = Guid.NewGuid(); var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c-A", Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }], DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }], Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-wrong", Name = "BAD NAME", DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "m" }], }; var errors = DraftValidator.Validate(draft); errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding"); errors.ShouldContain(e => e.Code == "EquipmentIdNotDerived"); errors.ShouldContain(e => e.Code == "UnsSegmentInvalid"); } // ------------------------------------------------------------------------------------ // Full-config probe — proves a realistic canonical deployed config passes ALL rules. // This guards the deploy-path gate flip (reject-on-ANY-error): if a clean canonical // config fired any rule, flipping the gate would block every deploy. // ------------------------------------------------------------------------------------ /// Probe for the deploy-path gate activation: a full, realistic config modelling /// the REAL deployed shape. Galaxy is now a standard Equipment-kind driver (the SystemPlatform /// namespace split is retired), so the GalaxyMxGateway driver + its Galaxy-hierarchy Tags /// (EquipmentId=null from the seed) live in the Equipment namespace alongside a Modbus driver /// + UNS area/line + canonical Equipment rows + VirtualTags from the company overlay. This must /// produce ZERO validation errors so the reject-on-any-error gate is safe to activate. [Fact] public void Full_realistic_config_passes_all_rules() { // Equipment side: Galaxy + Modbus drivers in a single Equipment namespace + UNS + canonical equipment. var eqNamespace = new Namespace { NamespaceId = "MAIN-OPCUA-equipment", ClusterId = "MAIN", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:zb:main:equipment", }; // Galaxy hierarchy (OtOpcUa seed shape): GalaxyMxGateway driver, Tags carry EquipmentId=null. var spDriver = new DriverInstance { DriverInstanceId = "main-galaxy", ClusterId = "MAIN", NamespaceId = eqNamespace.NamespaceId, Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", }; var eqDriver = new DriverInstance { DriverInstanceId = "main-modbus", ClusterId = "MAIN", NamespaceId = eqNamespace.NamespaceId, Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", }; var area = new UnsArea { UnsAreaId = "area-filling", ClusterId = "MAIN", Name = "filling" }; var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = area.UnsAreaId, Name = "line-1" }; // Canonical EquipmentIds — derived from the EquipmentUuid via the same rule the overlay loader uses. var rinserUuid = Guid.NewGuid(); var fillerUuid = Guid.NewGuid(); var rinser = new Equipment { EquipmentUuid = rinserUuid, EquipmentId = DraftValidator.DeriveEquipmentId(rinserUuid), Name = "rinser-01", DriverInstanceId = eqDriver.DriverInstanceId, UnsLineId = line.UnsLineId, MachineCode = "machine_001", ZTag = null, SAPID = null, }; var filler = new Equipment { EquipmentUuid = fillerUuid, EquipmentId = DraftValidator.DeriveEquipmentId(fillerUuid), Name = "filler-02", DriverInstanceId = eqDriver.DriverInstanceId, UnsLineId = line.UnsLineId, MachineCode = "machine_002", ZTag = null, SAPID = null, }; var draft = new DraftSnapshot { GenerationId = 0, ClusterId = string.Empty, // global snapshot — matches DraftSnapshotFactory.FromConfigDbAsync // Enterprise/Site left null — matches the deploy path's conservative fallback Namespaces = [eqNamespace], DriverInstances = [spDriver, eqDriver], UnsAreas = [area], UnsLines = [line], Equipment = [rinser, filler], Tags = [ // Galaxy-hierarchy tags from the seed: EquipmentId null, Galaxy folder hierarchy. BuildTag(equipmentId: null, name: "PV", folderPath: "Area.Tank01"), BuildTag(equipmentId: null, name: "SP", folderPath: "Area.Tank01"), ], VirtualTags = [ // One per equipment, names distinct within each owning equipment. 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}"))); } // ------------------------------------------------------------------------------------ // Driver-less Equipment namespace — core safety claim (feat/driverless-equipment-namespace) // ------------------------------------------------------------------------------------ /// Characterisation test: a draft that contains an Equipment-kind namespace with /// ZERO rows, and a single whose /// is , must pass /// with no errors. This locks in the design's core /// safety claim — the validator must never implicitly require a driver for Equipment-namespace /// equipment. [Fact] public void Validate_accepts_driverless_equipment_in_driverless_equipment_namespace() { // An Equipment-kind namespace — no DriverInstance rows at all for this namespace. var eqNamespace = new Namespace { NamespaceId = "MAIN-OPCUA-equipment", ClusterId = "MAIN", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:zb:main:equipment", }; // UNS topology required by ValidateUnsSegments / ValidatePathLength. var area = new UnsArea { UnsAreaId = "area-filling", ClusterId = "MAIN", Name = "filling" }; var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = area.UnsAreaId, Name = "line-1" }; // Canonical EquipmentId derived from UUID — satisfies ValidateEquipmentIdDerivation. var uuid = Guid.NewGuid(); var equipment = new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = "rinser-01", DriverInstanceId = null, // ← driver-less: the property under test UnsLineId = line.UnsLineId, MachineCode = "machine_001", ZTag = null, SAPID = null, }; var draft = new DraftSnapshot { GenerationId = 0, ClusterId = string.Empty, // global snapshot — matches DraftSnapshotFactory.FromConfigDbAsync Namespaces = [eqNamespace], // Equipment namespace present DriverInstances = [], // ← zero drivers: the other half of the safety claim UnsAreas = [area], UnsLines = [line], Equipment = [equipment], }; var errors = DraftValidator.Validate(draft); errors.ShouldBeEmpty( "a driver-less Equipment in an Equipment-kind namespace with no DriverInstances must pass " + "all validator rules; firing rules: " + string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}"))); } // ------------------------------------------------------------------------------------ // ValidateNoEquipmentSignalNameCollision — Tag/VirtualTag NodeId collision // ------------------------------------------------------------------------------------ /// Verifies that an equipment-bound Tag and a VirtualTag sharing the same /// equipment+name collide on a single OPC UA NodeId. [Fact] public void Tag_and_VirtualTag_same_equipment_and_name_collide() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Tags = [BuildTag(equipmentId: "eq-1", name: "speed", folderPath: null)], VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")], }; DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentSignalNameCollision"); } /// Verifies that a Tag in a sub-folder does not collide with a VirtualTag at the /// equipment root even when the names match — the NodeId keys differ. [Fact] public void Same_name_different_folder_does_not_collide() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Tags = [BuildTag(equipmentId: "eq-1", name: "speed", folderPath: "metrics")], VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")], }; DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision"); } /// Verifies that a FolderPath-scoped tag (EquipmentId == null) is excluded from the /// equipment-signal node space and so never collides with an equipment VirtualTag. [Fact] public void Unbound_tag_EquipmentId_null_does_not_collide_with_equipment_vtag() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", Tags = [BuildTag(equipmentId: null, name: "speed", folderPath: null)], VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")], }; DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision"); } private static Tag BuildTag( string? equipmentId, string name, string? folderPath, string driverInstanceId = "d", string tagConfig = "{}") => new() { TagId = $"tag-{name}", DriverInstanceId = driverInstanceId, EquipmentId = equipmentId, Name = name, FolderPath = folderPath, DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig, }; private static VirtualTag BuildVirtualTag(string equipmentId, string name) => new() { VirtualTagId = $"vtag-{name}", EquipmentId = equipmentId, Name = name, DataType = "Float", ScriptId = "s-1", }; // ------------------------------------------------------------------------------------ // ValidateGalaxyTagFullName — Galaxy equipment tags must carry TagConfig.FullName // ------------------------------------------------------------------------------------ /// Verifies that an equipment-scoped Tag bound to a GalaxyMxGateway driver whose /// TagConfig has no FullName (a Galaxy tag with no Galaxy reference) is rejected — it would /// subscribe to nothing. [Fact] public void GalaxyTag_missing_FullName_is_rejected() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", DriverInstances = [new DriverInstance { DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1", Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }], Tags = [BuildTag(equipmentId: "eq-1", name: "galaxytag", folderPath: null, driverInstanceId: "d-galaxy", tagConfig: "{}")], }; DraftValidator.Validate(draft).ShouldContain(e => e.Code == "GalaxyTagMissingReference" && e.Context == "tag-galaxytag"); } /// Verifies that an equipment-scoped Galaxy tag carrying a TagConfig.FullName /// reference is accepted. [Fact] public void GalaxyTag_with_FullName_is_accepted() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", DriverInstances = [new DriverInstance { DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1", Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }], Tags = [BuildTag(equipmentId: "eq-1", name: "galaxytag", folderPath: null, driverInstanceId: "d-galaxy", tagConfig: "{\"FullName\":\"X.Y\"}")], }; DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "GalaxyTagMissingReference"); } /// Verifies that an equipment-scoped Tag bound to a NON-Galaxy driver with an empty /// TagConfig is NOT flagged as a Galaxy tag missing its reference — only GalaxyMxGateway-bound /// equipment tags carry the Galaxy FullName requirement. [Fact] public void GalaxyTag_check_skips_nonGalaxy_equipment_tag() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", DriverInstances = [new DriverInstance { DriverInstanceId = "d-modbus", ClusterId = "c", NamespaceId = "ns-1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" }], Tags = [BuildTag(equipmentId: "eq-1", name: "galaxytag", folderPath: null, driverInstanceId: "d-modbus", tagConfig: "{}")], }; DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "GalaxyTagMissingReference"); } // ------------------------------------------------------------------------------------ // Phase 6.3 task #148 part 2 — ValidateClusterTopology // ------------------------------------------------------------------------------------ /// 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() { // Declared 2 + Hot, but one node disabled — runtime would boot InvalidTopology. 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"); } // v2: the "exactly one Primary per cluster" check is gone — Akka cluster's // role-leader-of-"driver" elects the primary at runtime. The corresponding // ValidateClusterTopology_flags_multiple_Primary test (and the // ClusterMultiplePrimary error code it asserted) were removed alongside Task 14d. /// 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() { // Craft a snapshot where the 32+32 approximation would flag PathTooLong but the // actual Enterprise/Site lengths would not. Equipment name intentionally exceeds the // UNS-segment 32-char limit (it would also trigger UnsSegmentInvalid — that is fine; // both checks are independent and the test is filtering only on PathTooLong). // // approx: 32+32 + 32+32+90 +4 = 222 > 200 → would flag PathTooLong // actual: 2 +1 + 32+32+90 +4 = 161 ≤ 200 → no PathTooLong 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, DriverInstanceId = "d", 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() { // Without Enterprise/Site on the snapshot the validator assumes 32+32. // A path whose segments sum to 93 chars (area=32 + line=32 + eq=29) fits // under 200 even with the 32+32 approximation (32+32+93+4 = 161 ≤ 200) and // must NOT be flagged — the fallback must not over-penalise valid paths that // would also be valid under real Enterprise/Site values. var areaId = "area-x"; var lineId = "line-y"; var uuid = Guid.NewGuid(); var snapshot = new DraftSnapshot { GenerationId = 1, ClusterId = "c", // Enterprise and Site deliberately omitted — conservative fallback path 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), DriverInstanceId = "d", 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"); } }