diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor new file mode 100644 index 00000000..a0016cff --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor @@ -0,0 +1,35 @@ +@* Typed TagConfig editor for the read-only Sql driver. MINIMAL PLACEHOLDER (Task 19) — this shell only + wires the standard editor parameter contract and round-trips the config through SqlTagConfigModel so no + edit is lost while the full UI is pending. Task 20 replaces the body with the model/table/column fields + (KeyValue vs WideRow row-selector). Dispatched from the TagModal via TagConfigEditorMap, so it takes the + same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter shape every typed editor + takes; DriverType/GetDriverConfigJson are accepted for dispatch uniformity. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors + +
Sql tag editor — coming soon (Task 20).
+ +@code { + /// The tag's current TagConfig JSON. + [Parameter] public string? ConfigJson { get; set; } + + /// Raised with the updated TagConfig JSON when the model changes. + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + + /// DriverType of the selected driver — accepted for dispatch uniformity. + [Parameter] public string DriverType { get; set; } = ""; + + /// Live accessor for the selected driver's DriverConfig JSON (browse connect config). + [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}"; + + private SqlTagConfigModel _m = new(); + private string? _lastConfigJson; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render can't reset + // an in-progress edit (mirrors the other typed editors). + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = SqlTagConfigModel.FromJson(ConfigJson); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs new file mode 100644 index 00000000..4ac7c9c2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs @@ -0,0 +1,212 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// +/// Typed working model for a read-only Sql tag's TagConfig JSON (the driver-specific table/column +/// mapping; name/writable live on the Tag entity). Mirrors — byte-for-byte on the fields it owns — the +/// authoritative runtime reader : the model and +/// type enums are written as their NAME strings (never numbers), and +/// enforces the same required-field/selector shape the parser accepts. Preserves unrecognised JSON keys +/// (top-level and inside rowSelector) across a load→save so a future field survives an edit. +/// +public sealed class SqlTagConfigModel +{ + /// Tag→value mapping model. v1 accepts and + /// ; is deferred and rejected. + public SqlTagModel Model { get; set; } = SqlTagModel.KeyValue; + + /// The table or view to read (e.g. dbo.TagValues). Required for every model. + public string? Table { get; set; } + + /// : the column matched against . + public string? KeyColumn { get; set; } + + /// : this tag's key value (bound as a parameter at read time). + public string? KeyValue { get; set; } + + /// : the column the value is read from. + public string? ValueColumn { get; set; } + + /// Optional source-timestamp column; absent ⇒ the driver stamps the poll time. Both models. + public string? TimestampColumn { get; set; } + + /// : the column this tag reads from the selected row. + public string? ColumnName { get; set; } + + /// : which row to read — a where-pair or newest-by-timestamp. + public SqlRowSelectorModel RowSelector { get; set; } = new(); + + /// Optional explicit OPC UA data type override (the blob's type field). null ⇒ the + /// driver infers the type from the result-set column metadata. + public DriverDataType? Type { get; set; } + + private JsonObject _bag = new(); + + // The raw offending string when "model"/"type" is present-but-invalid (a typo). Mirrors the parser's + // strict-enum reject: absent OR present-non-string ⇒ null (nothing to validate). + private string? _invalidModelRaw; + private string? _invalidTypeRaw; + + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every + /// original key (so fields this editor doesn't expose survive a load→save). + /// The raw TagConfig JSON string, or null for a new/empty config. + /// The populated . + public static SqlTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + var (model, invalidModel) = ReadEnumStrict(o, "model"); + var (type, invalidType) = ReadEnumStrict(o, "type"); + + var selectorObj = o.TryGetPropertyValue("rowSelector", out var sn) && sn is JsonObject so ? so : null; + + return new SqlTagConfigModel + { + Model = model ?? SqlTagModel.KeyValue, + Table = TagConfigJson.GetString(o, "table"), + KeyColumn = TagConfigJson.GetString(o, "keyColumn"), + KeyValue = TagConfigJson.GetString(o, "keyValue"), + ValueColumn = TagConfigJson.GetString(o, "valueColumn"), + TimestampColumn = TagConfigJson.GetString(o, "timestampColumn"), + ColumnName = TagConfigJson.GetString(o, "columnName"), + RowSelector = SqlRowSelectorModel.FromJson(selectorObj), + Type = type, + _bag = o, + _invalidModelRaw = invalidModel, + _invalidTypeRaw = invalidType, + }; + } + + /// Serialises this model back to a TagConfig JSON string, writing the exposed fields (enums as + /// their name strings) over the preserved key bag. The nested rowSelector object is rebuilt from + /// (preserving its own unknown keys) and omitted entirely when empty. + /// The serialised TagConfig JSON string. + public string ToJson() + { + TagConfigJson.Set(_bag, "model", Model); + TagConfigJson.Set(_bag, "table", Table); + TagConfigJson.Set(_bag, "keyColumn", KeyColumn); + TagConfigJson.Set(_bag, "keyValue", KeyValue); + TagConfigJson.Set(_bag, "valueColumn", ValueColumn); + TagConfigJson.Set(_bag, "timestampColumn", TimestampColumn); + TagConfigJson.Set(_bag, "columnName", ColumnName); + TagConfigJson.Set(_bag, "type", Type); + + var selector = RowSelector.ToJsonObject(); + if (selector is null) { _bag.Remove("rowSelector"); } + else { _bag["rowSelector"] = selector; } + + return TagConfigJson.Serialize(_bag); + } + + /// Validates the model against the exact accept/reject boundary of + /// . Returns an error message, or null when valid. + /// An error message describing the validation failure, or null when the model is valid. + public string? Validate() + { + // Strict enums (mirrors the parser's TryReadEnumStrict): a present-but-invalid string rejects the tag. + if (_invalidModelRaw is not null) { return $"model: '{_invalidModelRaw}' is not a valid Sql tag model."; } + if (_invalidTypeRaw is not null) { return $"type: '{_invalidTypeRaw}' is not a valid data type."; } + + // Query is design §5.4 / P3 — the parser rejects it; the editor must too. + if (Model == SqlTagModel.Query) { return "model 'Query' is deferred (P3) and not supported."; } + + if (string.IsNullOrWhiteSpace(Table)) { return "table is required."; } + + switch (Model) + { + case SqlTagModel.KeyValue: + if (string.IsNullOrWhiteSpace(KeyColumn)) { return "keyColumn is required for a KeyValue tag."; } + if (string.IsNullOrWhiteSpace(ValueColumn)) { return "valueColumn is required for a KeyValue tag."; } + if (KeyValue is null) { return "keyValue is required for a KeyValue tag."; } + return null; + + case SqlTagModel.WideRow: + if (string.IsNullOrWhiteSpace(ColumnName)) { return "columnName is required for a WideRow tag."; } + // A where-pair needs BOTH whereColumn and whereValue (whereValue may be an empty string, but + // must be present) — mirrors the parser's `!blank(whereColumn) && whereValue is not null`. + var hasWherePair = !string.IsNullOrWhiteSpace(RowSelector.WhereColumn) && RowSelector.WhereValue is not null; + if (!hasWherePair && string.IsNullOrWhiteSpace(RowSelector.TopByTimestamp)) + { + return "a WideRow tag needs a row selector: a whereColumn/whereValue pair, or topByTimestamp."; + } + return null; + + default: + return "model is not a supported Sql tag model."; + } + } + + // Reads an enum-valued string field the way the parser's TryReadEnumStrict does: absent OR present-but- + // non-string ⇒ (null, null) — nothing to validate; a present string that parses ⇒ (value, null); a + // present string that does NOT parse ⇒ (null, offendingString) so Validate can reject it. + private static (TEnum? value, string? invalidRaw) ReadEnumStrict(JsonObject o, string name) + where TEnum : struct, Enum + { + if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue(out var s)) + { + return (null, null); + } + return Enum.TryParse(s, ignoreCase: true, out var parsed) ? (parsed, null) : (null, s); + } +} + +/// +/// Typed working model for a tag's nested rowSelector object. +/// Holds a where-pair (/) or a newest-by-timestamp +/// selector (). Preserves unknown keys inside the selector across load→save. +/// +public sealed class SqlRowSelectorModel +{ + /// The column that picks the row (matched against ). + public string? WhereColumn { get; set; } + + /// The value is matched against (bound as a parameter at read time). + /// Captured as text whether the blob authored it as a JSON string or a JSON number/boolean, mirroring + /// the parser's scalar read. + public string? WhereValue { get; set; } + + /// Newest-row selector: the timestamp column to order by (used when no where-pair is authored). + public string? TopByTimestamp { get; set; } + + private JsonObject _bag = new(); + + /// Loads a selector model from the nested rowSelector JSON object (or a fresh empty model + /// when the object is absent), retaining any unknown keys. + /// The nested rowSelector object, or null when absent. + /// The populated . + public static SqlRowSelectorModel FromJson(JsonObject? o) + { + // Deep-clone so the selector owns an independent node tree (safe to re-attach on ToJson). + var bag = o?.DeepClone().AsObject() ?? new JsonObject(); + return new SqlRowSelectorModel + { + WhereColumn = TagConfigJson.GetString(bag, "whereColumn"), + WhereValue = ReadScalarText(bag, "whereValue"), + TopByTimestamp = TagConfigJson.GetString(bag, "topByTimestamp"), + _bag = bag, + }; + } + + /// Rebuilds the nested rowSelector JSON object from the exposed fields over the preserved + /// key bag, or returns null when the selector carries nothing (so the parent omits the key). + /// The rowSelector , or null when empty. + public JsonObject? ToJsonObject() + { + var o = _bag.DeepClone().AsObject(); + TagConfigJson.Set(o, "whereColumn", WhereColumn); + TagConfigJson.Set(o, "whereValue", WhereValue); + TagConfigJson.Set(o, "topByTimestamp", TopByTimestamp); + return o.Count == 0 ? null : o; + } + + // Reads a scalar as its textual form: a JSON string yields its contents, a number/boolean yields its raw + // token — so an authored whereValue of either shape is captured verbatim (mirrors the parser's ReadScalar). + private static string? ReadScalarText(JsonObject o, string name) + { + if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v) { return null; } + return v.TryGetValue(out var s) ? s : v.ToJsonString(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index 0ec4501b..e10c9a50 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; @@ -21,6 +22,10 @@ public static class TagConfigEditorMap [DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), [DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), [DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor), + // Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared + // constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests + // asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql. + [SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor), }; /// Returns the editor component type for a driver type, or null if none is registered. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs index f73e69d4..50ee89af 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; @@ -23,6 +24,8 @@ public static class TagConfigValidator [DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(), + // Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap. + [SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(), }; /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs new file mode 100644 index 00000000..bfd0ad86 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/SqlTagConfigModelTests.cs @@ -0,0 +1,277 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Tests for the typed AdminUI Sql tag-config model. The load-bearing property is editor-output ⇔ +/// parser-input agreement: what writes MUST parse cleanly through +/// (the runtime factory's authoritative reader), and the +/// model/type enums MUST serialise as NAME strings — a numeric enum makes the parser reject +/// the tag (the "authors fine, never reads" defect class this guards). +/// +public sealed class SqlTagConfigModelTests +{ + // ---- the plan's required assertion: string enums + unknown-key survival -------------------- + + [Fact] + public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys() + { + var m = SqlTagConfigModel.FromJson( + """{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}"""); + var json = m.ToJson(); + json.ShouldContain("\"KeyValue\""); + json.ShouldContain("\"Float64\""); + json.ShouldContain("customX"); // unknown key survives load→save + json.ShouldNotContain("\"model\":0"); + json.ShouldNotContain("\"type\":8"); // no numeric enum leaks + } + + // ---- defaults ------------------------------------------------------------------------------ + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = SqlTagConfigModel.FromJson(json); + + m.Model.ShouldBe(SqlTagModel.KeyValue); + m.Table.ShouldBeNull(); + m.KeyColumn.ShouldBeNull(); + m.KeyValue.ShouldBeNull(); + m.ValueColumn.ShouldBeNull(); + m.TimestampColumn.ShouldBeNull(); + m.ColumnName.ShouldBeNull(); + m.Type.ShouldBeNull(); + m.RowSelector.WhereColumn.ShouldBeNull(); + m.RowSelector.WhereValue.ShouldBeNull(); + m.RowSelector.TopByTimestamp.ShouldBeNull(); + } + + [Fact] + public void ToJson_of_default_model_omits_optional_keys() + { + var json = new SqlTagConfigModel { Table = "t" }.ToJson(); + + json.ShouldContain("\"model\":\"KeyValue\""); + json.ShouldContain("\"table\":\"t\""); + json.ShouldNotContain("type"); // no type set ⇒ key omitted + json.ShouldNotContain("rowSelector"); // no selector ⇒ key omitted + json.ShouldNotContain("timestampColumn"); + } + + // ---- round-trip fidelity through FromJson→ToJson→FromJson ---------------------------------- + + [Fact] + public void Round_trip_preserves_keyValue_fields() + { + var m = new SqlTagConfigModel + { + Model = SqlTagModel.KeyValue, + Table = "dbo.TagValues", + KeyColumn = "TagName", + KeyValue = "Line1.Speed", + ValueColumn = "Val", + TimestampColumn = "Ts", + Type = DriverDataType.Float64, + }; + + var m2 = SqlTagConfigModel.FromJson(m.ToJson()); + + m2.Model.ShouldBe(SqlTagModel.KeyValue); + m2.Table.ShouldBe("dbo.TagValues"); + m2.KeyColumn.ShouldBe("TagName"); + m2.KeyValue.ShouldBe("Line1.Speed"); + m2.ValueColumn.ShouldBe("Val"); + m2.TimestampColumn.ShouldBe("Ts"); + m2.Type.ShouldBe(DriverDataType.Float64); + } + + [Fact] + public void Round_trip_preserves_wideRow_wherePair_fields() + { + var m = new SqlTagConfigModel + { + Model = SqlTagModel.WideRow, + Table = "dbo.Wide", + ColumnName = "Speed", + RowSelector = new SqlRowSelectorModel { WhereColumn = "DeviceId", WhereValue = "42" }, + }; + + var m2 = SqlTagConfigModel.FromJson(m.ToJson()); + + m2.Model.ShouldBe(SqlTagModel.WideRow); + m2.Table.ShouldBe("dbo.Wide"); + m2.ColumnName.ShouldBe("Speed"); + m2.RowSelector.WhereColumn.ShouldBe("DeviceId"); + m2.RowSelector.WhereValue.ShouldBe("42"); + m2.RowSelector.TopByTimestamp.ShouldBeNull(); + } + + [Fact] + public void Round_trip_preserves_wideRow_topByTimestamp_fields() + { + var m = new SqlTagConfigModel + { + Model = SqlTagModel.WideRow, + Table = "dbo.Wide", + ColumnName = "Speed", + RowSelector = new SqlRowSelectorModel { TopByTimestamp = "Ts" }, + }; + + var m2 = SqlTagConfigModel.FromJson(m.ToJson()); + + m2.Model.ShouldBe(SqlTagModel.WideRow); + m2.ColumnName.ShouldBe("Speed"); + m2.RowSelector.TopByTimestamp.ShouldBe("Ts"); + m2.RowSelector.WhereColumn.ShouldBeNull(); + m2.RowSelector.WhereValue.ShouldBeNull(); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys_top_level_and_nested() + { + var json = SqlTagConfigModel + .FromJson( + """{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"Ts","futureKey":"keep"},"scaling":2.5}""") + .ToJson(); + + json.ShouldContain("scaling"); // unknown top-level key survives + json.ShouldContain("2.5"); + json.ShouldContain("futureKey"); // unknown nested (rowSelector) key survives + json.ShouldContain("keep"); + json.ShouldContain("\"topByTimestamp\":\"Ts\""); + } + + // ---- Validate() mirrors the parser's accept/reject boundary -------------------------------- + + [Fact] + public void Validate_returns_null_for_valid_keyValue() + => SqlTagConfigModel.FromJson( + """{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_returns_null_for_valid_wideRow_wherePair() + => SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"whereColumn":"id","whereValue":"1"}}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_returns_null_for_valid_wideRow_topByTimestamp() + => SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"t","columnName":"c","rowSelector":{"topByTimestamp":"Ts"}}""") + .Validate().ShouldBeNull(); + + [Fact] + public void Validate_rejects_missing_table() + => SqlTagConfigModel.FromJson("""{"model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_keyValue_missing_valueColumn() + => SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_keyValue_missing_keyValue_field() + => SqlTagConfigModel.FromJson("""{"model":"KeyValue","table":"t","keyColumn":"k","valueColumn":"c"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_wideRow_without_a_selector() + => SqlTagConfigModel.FromJson("""{"model":"WideRow","table":"t","columnName":"c"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_wideRow_missing_columnName() + => SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"t","rowSelector":{"topByTimestamp":"Ts"}}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_invalid_model_enum() + => SqlTagConfigModel.FromJson("""{"model":"Bogus","table":"t"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_invalid_type_enum() + => SqlTagConfigModel.FromJson( + """{"model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Bogus"}""") + .Validate().ShouldNotBeNull(); + + [Fact] + public void Validate_rejects_deferred_query_model() + => SqlTagConfigModel.FromJson("""{"model":"Query","table":"t"}""") + .Validate().ShouldNotBeNull(); + + // ---- LOAD-BEARING: editor output MUST parse through the runtime factory reader -------------- + + [Theory] + [InlineData("""{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Val","timestampColumn":"Ts","type":"Float64"}""")] + [InlineData("""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"whereColumn":"DeviceId","whereValue":"42"}}""")] + [InlineData("""{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"topByTimestamp":"Ts"}}""")] + public void ToJson_output_parses_cleanly_through_SqlEquipmentTagParser(string authored) + { + // Round-trip the authored blob through the editor model, exactly as the TagModal save path does. + var roundTripped = SqlTagConfigModel.FromJson(authored).ToJson(); + + // The authoritative runtime reader must accept it — this is the editor-output ⇔ parser-input contract. + SqlEquipmentTagParser.TryParse(roundTripped, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue(); + def.Name.ShouldBe("Plant/Sql/dev1/Speed"); + def.Table.ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public void ToJson_keyValue_output_parses_to_expected_definition_fields() + { + var json = SqlTagConfigModel.FromJson( + """{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","keyValue":"Line1.Speed","valueColumn":"Val","timestampColumn":"Ts","type":"Float64"}""") + .ToJson(); + + SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.KeyValue); + def.Table.ShouldBe("dbo.TagValues"); + def.KeyColumn.ShouldBe("TagName"); + def.KeyValue.ShouldBe("Line1.Speed"); + def.ValueColumn.ShouldBe("Val"); + def.TimestampColumn.ShouldBe("Ts"); + def.DeclaredType.ShouldBe(DriverDataType.Float64); + } + + [Fact] + public void ToJson_wideRow_wherePair_output_parses_to_expected_definition_fields() + { + var json = SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"whereColumn":"DeviceId","whereValue":"42"}}""") + .ToJson(); + + SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.WideRow); + def.ColumnName.ShouldBe("Speed"); + def.RowSelectorColumn.ShouldBe("DeviceId"); + def.RowSelectorValue.ShouldBe("42"); + def.RowSelectorTopByTimestamp.ShouldBeNull(); + } + + [Fact] + public void ToJson_wideRow_topByTimestamp_output_parses_to_expected_definition_fields() + { + var json = SqlTagConfigModel.FromJson( + """{"model":"WideRow","table":"dbo.Wide","columnName":"Speed","rowSelector":{"topByTimestamp":"Ts"}}""") + .ToJson(); + + SqlEquipmentTagParser.TryParse(json, "raw/path", out var def).ShouldBeTrue(); + def.Model.ShouldBe(SqlTagModel.WideRow); + def.ColumnName.ShouldBe("Speed"); + def.RowSelectorTopByTimestamp.ShouldBe("Ts"); + def.RowSelectorColumn.ShouldBeNull(); + def.RowSelectorValue.ShouldBeNull(); + } +}