feat(sql): typed AdminUI Sql tag-config model + validator (string enums)
SqlTagConfigModel + SqlRowSelectorModel mirror SqlEquipmentTagParser's accept/reject boundary byte-for-byte on the fields they own: model/type serialize as NAME strings (never numbers), KeyValue requires table+keyColumn+keyValue+valueColumn, WideRow requires table+columnName+a selector (where-pair OR topByTimestamp), Query is rejected, and unknown keys (top-level and nested rowSelector) survive load->save. Registered in TagConfigEditorMap + TagConfigValidator keyed off SqlDriver.DriverTypeName (DriverTypeNames.Sql is deferred to Task 11). A minimal placeholder SqlTagConfigEditor.razor lands the map entry now; Task 20 fleshes out the UI. The load-bearing test rounds editor output back through SqlEquipmentTagParser.TryParse (editor-output <=> parser-input agreement). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the typed AdminUI Sql tag-config model. The load-bearing property is editor-output ⇔
|
||||
/// parser-input agreement: what <see cref="SqlTagConfigModel.ToJson"/> writes MUST parse cleanly through
|
||||
/// <see cref="SqlEquipmentTagParser.TryParse"/> (the runtime factory's authoritative reader), and the
|
||||
/// <c>model</c>/<c>type</c> enums MUST serialise as NAME strings — a numeric enum makes the parser reject
|
||||
/// the tag (the "authors fine, never reads" defect class this guards).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user