From a9b7a5002a0975df07790a1aa454e847032d4f63 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:53:13 -0400 Subject: [PATCH] feat(core-abstractions): TagConfigJson shared strict-capable field readers (R2-11, 05/CONV-2) --- ...tagconfig-consolidation-plan.md.tasks.json | 2 +- .../TagConfigJson.cs | 100 ++++++++++++++++++ .../TagConfigJsonTests.cs | 79 ++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagConfigJsonTests.cs diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index f2a05e27..baa6b73e 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -12,7 +12,7 @@ { "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "completed", "blockedBy": ["T2", "T3"] }, { "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "completed", "blockedBy": ["T2", "T3"] }, { "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "completed", "blockedBy": ["T3", "T5"] }, - { "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "pending", "blockedBy": [] }, + { "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "completed", "blockedBy": [] }, { "id": "T12", "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "pending", "blockedBy": ["T11"] }, { "id": "T13", "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", "status": "pending", "blockedBy": ["T11"] }, { "id": "T14", "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", "status": "pending", "blockedBy": ["T11"] }, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs new file mode 100644 index 00000000..950b08a9 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs @@ -0,0 +1,100 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// The outcome of reading an enum-valued field from a TagConfig JSON object. +public enum JsonEnumRead +{ + /// The property is absent, or present but not a JSON string (nothing to validate). + Absent, + + /// The property is a JSON string that parses (case-insensitively) to the enum. + Valid, + + /// The property is a JSON string that does NOT parse to the enum (a typo'd value). + Invalid, +} + +/// +/// Shared TagConfig JSON field readers for the six equipment-tag parsers (05/CONV-2). Replaces the +/// six byte-identical private ReadEnum copies with one strict-capable home beside +/// . +/// Unknown-key handling is a deliberate non-goal. The TagConfig blob is a SHARED +/// namespace — platform intent keys (FullName, alarm, isHistorized, …) coexist +/// with per-driver address keys (region, dataType, …) and forward-compat keys the typed +/// editors preserve. There is no single canonical schema, so these readers read the known fields and +/// ignore the rest; they NEVER warn on unknown keys. +/// +public static class TagConfigJson +{ + /// + /// Reads an enum-valued string field, distinguishing absent / valid / present-but-invalid so callers + /// can be lenient (fall back) while still reporting the invalid case. A property that is + /// absent or present-but-non-string yields (there is no enum + /// string to validate); a present JSON string that parses case-insensitively yields + /// ; a present JSON string that does not parse yields + /// . + /// + /// The enum type to parse. + /// The TagConfig root object. + /// The property name to read. + /// The parsed enum on ; otherwise default. + /// The read outcome. + public static JsonEnumRead TryReadEnum(JsonElement o, string name, out TEnum value) + where TEnum : struct, Enum + { + if (!o.TryGetProperty(name, out var e) || e.ValueKind != JsonValueKind.String) + { + value = default; + return JsonEnumRead.Absent; + } + if (Enum.TryParse(e.GetString(), ignoreCase: true, out value)) + return JsonEnumRead.Valid; + value = default; + return JsonEnumRead.Invalid; + } + + /// + /// Lenient enum read with today's exact semantics: absent OR invalid ⇒ ; + /// only a present, parseable string yields the parsed value. Bit-for-bit equivalent to the six + /// retired private ReadEnum copies. + /// + /// The enum type to parse. + /// The TagConfig root object. + /// The property name to read. + /// The value returned when the field is absent or invalid. + /// The parsed enum, or . + public static TEnum ReadEnumOrDefault(JsonElement o, string name, TEnum fallback) + where TEnum : struct, Enum + => TryReadEnum(o, name, out var v) == JsonEnumRead.Valid ? v : fallback; + + /// + /// Reads the optional "writable" flag with AbCip semantics (the model): an explicit JSON + /// false; anything else (absent, true, or a non-bool token) + /// ⇒ . + /// + /// The TagConfig root object. + /// The value returned unless the field is an explicit JSON false. + /// only for an explicit false; otherwise . + public static bool ReadWritable(JsonElement o, bool defaultValue = true) + => o.TryGetProperty("writable", out var e) && e.ValueKind == JsonValueKind.False + ? false + : defaultValue; + + /// + /// A human-readable warning for a present-but-invalid enum field (naming the field, the offending + /// value, and the valid enum members), or when the field is absent or valid. + /// + /// The enum type expected. + /// The TagConfig root object. + /// The property name to describe. + /// The warning text, or . + public static string? DescribeInvalidEnum(JsonElement o, string name) + where TEnum : struct, Enum + { + if (TryReadEnum(o, name, out _) != JsonEnumRead.Invalid) return null; + var raw = o.TryGetProperty(name, out var e) ? e.GetString() : null; + var valid = string.Join(", ", Enum.GetNames()); + return $"value '{raw}' for '{name}' is not a valid {typeof(TEnum).Name}; valid: {valid}"; + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagConfigJsonTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagConfigJsonTests.cs new file mode 100644 index 00000000..b748551b --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagConfigJsonTests.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; + +/// +/// Pins the shared strict-capable TagConfig field readers (R2-11, 05/CONV-2): the +/// Absent/Valid/Invalid matrix (case-insensitive), +/// lenient equivalence to the retired copies, +/// explicit-false-only semantics, and +/// messages. +/// +public sealed class TagConfigJsonTests +{ + /// Sample enum for the reader matrix (public so it can be an [InlineData] arg). + public enum Sample { Alpha, Beta, Gamma } + + private static JsonElement Root(string json) => JsonDocument.Parse(json).RootElement; + + [Theory] + [InlineData("{\"k\":\"Beta\"}", JsonEnumRead.Valid, Sample.Beta)] + [InlineData("{\"k\":\"beta\"}", JsonEnumRead.Valid, Sample.Beta)] // case-insensitive + [InlineData("{\"k\":\"GAMMA\"}", JsonEnumRead.Valid, Sample.Gamma)] + [InlineData("{\"k\":\"Deltaa\"}", JsonEnumRead.Invalid, default(Sample))] // typo → Invalid + [InlineData("{}", JsonEnumRead.Absent, default(Sample))] // absent + [InlineData("{\"k\":123}", JsonEnumRead.Absent, default(Sample))] // non-string → Absent (lenient) + [InlineData("{\"k\":null}", JsonEnumRead.Absent, default(Sample))] + public void TryReadEnum_matrix(string json, JsonEnumRead expected, Sample expectedValue) + { + var outcome = TagConfigJson.TryReadEnum(Root(json), "k", out var v); + outcome.ShouldBe(expected); + v.ShouldBe(expectedValue); + } + + [Theory] + [InlineData("{\"k\":\"Gamma\"}", Sample.Gamma)] // valid → parsed + [InlineData("{\"k\":\"typo\"}", Sample.Alpha)] // invalid → fallback + [InlineData("{}", Sample.Alpha)] // absent → fallback + [InlineData("{\"k\":42}", Sample.Alpha)] // non-string → fallback + public void ReadEnumOrDefault_is_lenient(string json, Sample expected) + { + TagConfigJson.ReadEnumOrDefault(Root(json), "k", Sample.Alpha).ShouldBe(expected); + } + + [Theory] + [InlineData("{\"writable\":false}", true, false)] // explicit false → false + [InlineData("{\"writable\":true}", true, true)] // explicit true → default + [InlineData("{}", true, true)] // absent → default + [InlineData("{\"writable\":\"no\"}", true, true)] // non-bool → default + [InlineData("{}", false, false)] // absent, default false + [InlineData("{\"writable\":false}", false, false)] // explicit false, default false + public void ReadWritable_explicit_false_only(string json, bool dflt, bool expected) + { + TagConfigJson.ReadWritable(Root(json), dflt).ShouldBe(expected); + } + + [Fact] + public void DescribeInvalidEnum_names_field_value_and_valid_members() + { + var msg = TagConfigJson.DescribeInvalidEnum(Root("{\"dataType\":\"Betaa\"}"), "dataType"); + msg.ShouldNotBeNull(); + msg.ShouldContain("Betaa"); + msg.ShouldContain("dataType"); + msg.ShouldContain("Alpha"); + msg.ShouldContain("Beta"); + msg.ShouldContain("Gamma"); + } + + [Theory] + [InlineData("{\"dataType\":\"Beta\"}")] // valid → null + [InlineData("{}")] // absent → null + [InlineData("{\"dataType\":5}")] // non-string → null + public void DescribeInvalidEnum_null_when_absent_or_valid(string json) + { + TagConfigJson.DescribeInvalidEnum(Root(json), "dataType").ShouldBeNull(); + } +}