using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Modbus; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; /// /// Review r3 N3 (#741, fix round 4): one authority for a Modbus tag's address. Every persisted /// TagConfig shape goes through EVERY consumer that decides, displays or rewrites the address. They /// must all agree with the driver, under every family: /// /// the driver's tag reader (ModbusTagDefinitionFactory.FromTagConfig with the driver's /// family, the exact call ModbusDriver makes at initialize); /// the deploy gate (EquipmentTagConfigInspector.Inspect); /// the editor model's load (what its fields claim), save (incl. a Writable toggle) and /// Validate; /// the CSV export → import round trip through the real RawTagCsvMapper (the export residual, /// not the stored blob, is the import base). /// /// The expected verdict per family is pinned in the table, so the authority itself cannot drift either. /// Three rounds converged on the rule one consumer at a time; this matrix is what makes a new divergence /// fail in one place. /// public sealed class ModbusAddressAuthorityAgreementTests { private static readonly (ModbusFamily Family, MelsecFamily Sub, string Label)[] Families = [ (ModbusFamily.Generic, MelsecFamily.Q_L_iQR, "Generic"), (ModbusFamily.DL205, MelsecFamily.Q_L_iQR, "DL205"), (ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, "MELSEC-Q"), (ModbusFamily.MELSEC, MelsecFamily.F_iQF, "MELSEC-F"), ]; /// /// Shape name, persisted blob, and the expected driver verdict per family in the order of /// (IR5 = InputRegisters[5], - = refused). /// public static TheoryData Shapes => new() { { "keys only", """{"region":"InputRegisters","address":5}""", "IR5|IR5|IR5|IR5" }, { "pre-lane keys + string", """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""", "IR5|IR5|IR5|IR5" }, { "pre-lane stamped HR/0 + string", """{"region":"HoldingRegisters","address":0,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0,"addressString":"D100"}""", "HR0|HR0|HR0|HR0" }, { "string only", """{"addressString":"40011:F"}""", "HR10|HR10|HR10|HR10" }, { "string only, family-native", """{"addressString":"D100"}""", "-|-|HR100|HR100" }, { "string + marker + keys", """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""", "HR100|HR100|HR100|HR100" }, { "string + marker, no keys", """{"addressString":"40011:F","addressResolved":true}""", "HR10|HR10|HR10|HR10" }, { "string + marker, no keys, family-native", """{"addressString":"D100","addressResolved":true}""", "-|-|HR100|HR100" }, { "malformed string", """{"addressString":"ZZ9"}""", "-|-|-|-" }, { "malformed string + marker", """{"addressString":"ZZ9","addressResolved":true}""", "-|-|-|-" }, { "malformed string + keys", """{"region":"InputRegisters","address":5,"addressString":"ZZ9"}""", "IR5|IR5|IR5|IR5" }, { "non-string addressString", """{"addressString":5}""", "-|-|-|-" }, { "string + one key", """{"addressString":"40011:F","region":"InputRegisters"}""", "HR10|HR10|HR10|HR10" }, { "no address", """{"writable":true}""", "-|-|-|-" }, { "bit region, legacy Int16", """{"region":"Coils","address":3,"dataType":"Int16"}""", "CO3|CO3|CO3|CO3" }, }; [Theory] [MemberData(nameof(Shapes))] public void Every_consumer_agrees_with_the_driver_on_every_persisted_shape(string shape, string blob, string expected) { var want = expected.Split('|'); var anyServed = want.Any(v => v != "-"); // 1. The driver (the authority) — pinned, so the authority cannot drift unnoticed either. for (var i = 0; i < Families.Length; i++) { Verdict(blob, i).ShouldBe(want[i], $"{shape}: driver / {Families[i].Label}"); } // 2. The deploy gate: clean exactly when some family's driver serves the tag. (EquipmentTagConfigInspector.Inspect(DriverTypeNames.Modbus, blob).Count == 0) .ShouldBe(anyServed, $"{shape}: deploy gate accepts ⇔ some driver serves it"); // 3. Editor load: when the model presents its fields as the address, they ARE the driver's address. var loaded = ModbusTagConfigModel.FromJson(blob); if (!loaded.IsStringOnly) { for (var i = 0; i < Families.Length; i++) { if (want[i] == "-") continue; FieldsVerdict(loaded).ShouldBe(want[i], $"{shape}: editor fields / {Families[i].Label}"); } } // 4. Editor save, untouched and after a Writable toggle (the round trip that remapped N1 / N3). var untouched = ModbusTagConfigModel.FromJson(blob).ToJson(); var toggled = ModbusTagConfigModel.FromJson(blob); toggled.Writable = false; var toggledJson = toggled.ToJson(); for (var i = 0; i < Families.Length; i++) { Verdict(untouched, i).ShouldBe(want[i], $"{shape}: editor save / {Families[i].Label}"); Verdict(toggledJson, i).ShouldBe(want[i], $"{shape}: editor save after Writable toggle / {Families[i].Label}"); } // 5. Validate: accepts exactly what some family's driver serves. (ModbusTagConfigModel.FromJson(blob).Validate() is null).ShouldBe(anyServed, $"{shape}: Validate"); // 6. CSV export → import through the production mapper (the residual is the import base). var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus, [new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]); var parsed = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv); parsed.FatalError.ShouldBeNull(); var row = parsed.Rows.ShouldHaveSingleItem(); if (!row.Ok) { anyServed.ShouldBeFalse($"{shape}: CSV import refused a tag the driver serves ({row.Error})"); } else { for (var i = 0; i < Families.Length; i++) { Verdict(row.ImportRow!.Tag.TagConfig, i).ShouldBe(want[i], $"{shape}: CSV export → import / {Families[i].Label}"); } } } private static string Verdict(string json, int familyIndex) { var (family, sub, _) = Families[familyIndex]; return ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, sub, out var def) ? Short(def.Region) + def.Address : "-"; } private static string FieldsVerdict(ModbusTagConfigModel m) => Short(m.Region) + m.Address; private static string Short(ModbusRegion r) => r switch { ModbusRegion.HoldingRegisters => "HR", ModbusRegion.InputRegisters => "IR", ModbusRegion.Coils => "CO", ModbusRegion.DiscreteInputs => "DI", _ => r.ToString(), }; // ------------------------------------------------------------------ CSV metadata (review r3 m1 / m3) private static RawCsvReviewRow RoundTrip(string blob, Func? editCsv = null) { var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus, [new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]); if (editCsv is not null) csv = editCsv(csv); return RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv).Rows.ShouldHaveSingleItem(); } /// m1: an UNCHANGED export → import of a pinned row keeps its address string and marker. [Fact] public void An_unchanged_csv_round_trip_of_a_pinned_row_keeps_its_address_string_and_marker() { const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}"""; var row = RoundTrip(pinned); row.Ok.ShouldBeTrue(row.Error); var json = row.ImportRow!.Tag.TagConfig; json.ShouldContain("\"addressString\":\"D100\""); json.ShouldContain("\"addressResolved\":true"); row.TypedOverrodeFallback.ShouldBeEmpty("nothing was overridden"); ModbusTagConfigModel.FromJson(json).IsPinned.ShouldBeTrue(); } /// m1 positive control: an address cell that CONTRADICTS the string drops it, and the grid says so. [Fact] public void A_csv_address_cell_that_contradicts_the_string_drops_it_and_the_review_grid_reports_it() { const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}"""; // Edit the exported Address cell 100 → 101. var row = RoundTrip(pinned, csv => csv.Replace(",HoldingRegisters,100,", ",HoldingRegisters,101,", StringComparison.Ordinal)); row.Ok.ShouldBeTrue(row.Error); var json = row.ImportRow!.Tag.TagConfig; json.ShouldNotContain("addressString"); json.ShouldNotContain("addressResolved"); row.TypedOverrodeFallback.ShouldNotBeEmpty("the dropped string is reported, not silent"); ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue(); def.Address.ShouldBe((ushort)101); } /// m3: the n5 case on an UNMARKED string-only row — typed address cells are applied and drop the string. [Fact] public void A_typed_csv_address_cell_on_an_unmarked_string_only_row_is_applied_and_drops_the_string() { var map = CsvColumnMap.Resolve(DriverTypeNames.Modbus).ShouldNotBeNull(); var merged = map.ApplyTypedColumns("""{"addressString":"40011:F"}""", new Dictionary(StringComparer.Ordinal) { ["Region"] = "InputRegisters", ["Address"] = "7" }); merged.ShouldNotContain("addressString"); foreach (var (family, sub, label) in Families) { ModbusTagDefinitionFactory.FromTagConfig(merged, "T", family, sub, out var def).ShouldBeTrue(label); def.Region.ShouldBe(ModbusRegion.InputRegisters, label); def.Address.ShouldBe((ushort)7, label); } } /// N3, by name: the marker without keys is string-only for the editor, its save and its CSV export. [Fact] public void A_marker_without_keys_is_string_only_and_never_saved_as_HR0() { var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F","addressResolved":true}"""); m.IsStringOnly.ShouldBeTrue(); m.IsPinned.ShouldBeFalse("a marker without keys pins nothing"); m.Writable = false; var saved = m.ToJson(); saved.ShouldNotContain("\"address\":"); saved.ShouldNotContain("\"region\":"); CsvColumnMap.Resolve(DriverTypeNames.Modbus)!.ReadTypedColumns(saved).ShouldNotContainKey("Address"); } /// The editor never writes default keys it did not resolve: a Writable toggle on an address-less blob /// does not author HoldingRegisters[0]; setting a field does. [Fact] public void An_address_less_blob_gains_keys_only_when_a_field_is_authored() { var m = ModbusTagConfigModel.FromJson("{}"); m.Writable = false; m.ToJson().ShouldNotContain("\"region\":"); m.HasKeyAddress.ShouldBeFalse(); m.Address = 0; // an explicit authoring of HR[0] is honoured m.HasKeyAddress.ShouldBeTrue(); ModbusTagDefinitionFactory.FromTagConfig(m.ToJson(), "T", out var def).ShouldBeTrue(); def.Address.ShouldBe((ushort)0); } }