feat(ui): structured editors for script schemas and alarm triggers
Replace raw-JSON text inputs with rich UI: script parameter/return types use a JSON Schema builder (SchemaBuilder + JsonSchemaShapeParser, with a migration to convert existing definitions); alarm trigger config uses a type-aware editor with a flattened attribute picker (AlarmTriggerEditor). AlarmActor gains optional direction (rising/falling/either) on RateOfChange triggers.
This commit is contained in:
@@ -1,150 +0,0 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
public class ParameterListEditorTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void NullJson_RendersEmptyState()
|
||||
{
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, (string?)null));
|
||||
|
||||
Assert.Contains("No parameters defined", cut.Markup);
|
||||
Assert.DoesNotContain("alert-warning", cut.Markup);
|
||||
Assert.DoesNotContain("alert-info", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidJson_RendersOneRowPerParameter()
|
||||
{
|
||||
var json = """[{"name":"id","type":"Integer"},{"name":"label","type":"String"}]""";
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
||||
|
||||
var nameInputs = cut.FindAll("input[aria-label='Parameter name']");
|
||||
Assert.Equal(2, nameInputs.Count);
|
||||
Assert.Equal("id", nameInputs[0].GetAttribute("value"));
|
||||
Assert.Equal("label", nameInputs[1].GetAttribute("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyLowercaseType_NormalizedAndFlagged()
|
||||
{
|
||||
var json = """[{"name":"x","type":"string"}]""";
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
||||
|
||||
var typeSelect = cut.Find("select[aria-label='Parameter type']");
|
||||
Assert.Equal("String", typeSelect.GetAttribute("value"));
|
||||
Assert.Contains("normalized", cut.Markup);
|
||||
Assert.Contains("alert-info", cut.Markup);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("int32", "Integer")]
|
||||
[InlineData("Int64", "Integer")]
|
||||
[InlineData("Double", "Float")]
|
||||
[InlineData("DateTime", "String")]
|
||||
[InlineData("bool", "Boolean")]
|
||||
public void LegacyDotNetType_NormalizedToCanonical(string raw, string expected)
|
||||
{
|
||||
var json = "[{\"name\":\"x\",\"type\":\"" + raw + "\"}]";
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
||||
|
||||
Assert.Equal(expected, cut.Find("select[aria-label='Parameter type']").GetAttribute("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonical_TypesDoNotTriggerNormalizedNotice()
|
||||
{
|
||||
var json = """[{"name":"x","type":"Integer"}]""";
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
||||
|
||||
Assert.DoesNotContain("alert-info", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddParameter_EmitsJsonWithNewRow()
|
||||
{
|
||||
string? captured = null;
|
||||
var cut = Render<ParameterListEditor>(p => p
|
||||
.Add(c => c.Json, (string?)null)
|
||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
||||
|
||||
cut.Find("button.btn-outline-secondary").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Contains("\"type\":\"String\"", captured);
|
||||
Assert.Contains("\"name\":\"\"", captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveParameter_EmitsNullWhenLastRowRemoved()
|
||||
{
|
||||
string? captured = "initial";
|
||||
var json = """[{"name":"x","type":"Integer"}]""";
|
||||
var cut = Render<ParameterListEditor>(p => p
|
||||
.Add(c => c.Json, json)
|
||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
||||
|
||||
cut.Find("button[aria-label^='Remove parameter']").Click();
|
||||
|
||||
Assert.Null(captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListType_RendersItemTypeSelect()
|
||||
{
|
||||
var json = """[{"name":"tags","type":"List","itemType":"String"}]""";
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
||||
|
||||
var itemTypeSelect = cut.Find("select[aria-label='List item type']");
|
||||
Assert.Equal("String", itemTypeSelect.GetAttribute("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonListType_HidesItemTypeSelect()
|
||||
{
|
||||
var json = """[{"name":"x","type":"Integer"}]""";
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
||||
|
||||
Assert.Empty(cut.FindAll("select[aria-label='List item type']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredFalseInJson_RendersUncheckedCheckbox()
|
||||
{
|
||||
var json = """[{"name":"x","type":"Integer","required":false}]""";
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
||||
|
||||
var checkbox = cut.Find("input[type='checkbox'][aria-label='Required']");
|
||||
Assert.Null(checkbox.GetAttribute("checked"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredOmitted_DefaultsToChecked()
|
||||
{
|
||||
var json = """[{"name":"x","type":"Integer"}]""";
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
||||
|
||||
var checkbox = cut.Find("input[type='checkbox'][aria-label='Required']");
|
||||
Assert.NotNull(checkbox.GetAttribute("checked"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidJson_RendersStartFreshButton()
|
||||
{
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, "not valid json"));
|
||||
|
||||
Assert.Contains("alert-warning", cut.Markup);
|
||||
Assert.Contains("Start fresh", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonArrayJson_RendersExpectedArrayError()
|
||||
{
|
||||
var cut = Render<ParameterListEditor>(p => p.Add(c => c.Json, """{"not":"an array"}"""));
|
||||
|
||||
Assert.Contains("Expected a JSON array", cut.Markup);
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
public class ReturnTypeEditorTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void NullJson_RendersNoReturnSelected()
|
||||
{
|
||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, (string?)null));
|
||||
|
||||
Assert.Equal("", cut.Find("select[aria-label='Return type']").GetAttribute("value"));
|
||||
Assert.Empty(cut.FindAll("select[aria-label='List item type']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimpleType_RendersSelected()
|
||||
{
|
||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, """{"type":"Boolean"}"""));
|
||||
|
||||
Assert.Equal("Boolean", cut.Find("select[aria-label='Return type']").GetAttribute("value"));
|
||||
Assert.Empty(cut.FindAll("select[aria-label='List item type']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListType_RendersItemTypeSelect()
|
||||
{
|
||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, """{"type":"List","itemType":"Integer"}"""));
|
||||
|
||||
Assert.Equal("List", cut.Find("select[aria-label='Return type']").GetAttribute("value"));
|
||||
Assert.Equal("Integer", cut.Find("select[aria-label='List item type']").GetAttribute("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyLowercaseType_NormalizedAndFlagged()
|
||||
{
|
||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, """{"type":"string"}"""));
|
||||
|
||||
Assert.Equal("String", cut.Find("select[aria-label='Return type']").GetAttribute("value"));
|
||||
Assert.Contains("normalized", cut.Markup);
|
||||
Assert.Contains("alert-info", cut.Markup);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Int32", "Integer")]
|
||||
[InlineData("Double", "Float")]
|
||||
[InlineData("DateTime", "String")]
|
||||
[InlineData("bool", "Boolean")]
|
||||
public void LegacyDotNetType_NormalizedToCanonical(string raw, string expected)
|
||||
{
|
||||
var json = "{\"type\":\"" + raw + "\"}";
|
||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, json));
|
||||
|
||||
Assert.Equal(expected, cut.Find("select[aria-label='Return type']").GetAttribute("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalType_DoesNotTriggerNormalizedNotice()
|
||||
{
|
||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, """{"type":"Integer"}"""));
|
||||
|
||||
Assert.DoesNotContain("alert-info", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeType_EmitsCanonicalJson()
|
||||
{
|
||||
string? captured = null;
|
||||
var cut = Render<ReturnTypeEditor>(p => p
|
||||
.Add(c => c.Json, (string?)null)
|
||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
||||
|
||||
cut.Find("select[aria-label='Return type']").Change("Boolean");
|
||||
|
||||
Assert.Equal("""{"type":"Boolean"}""", captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangeTypeToList_EmitsWithItemType()
|
||||
{
|
||||
string? captured = null;
|
||||
var cut = Render<ReturnTypeEditor>(p => p
|
||||
.Add(c => c.Json, (string?)null)
|
||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
||||
|
||||
cut.Find("select[aria-label='Return type']").Change("List");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Contains("\"type\":\"List\"", captured);
|
||||
Assert.Contains("\"itemType\":\"String\"", captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearType_EmitsNull()
|
||||
{
|
||||
string? captured = "initial";
|
||||
var cut = Render<ReturnTypeEditor>(p => p
|
||||
.Add(c => c.Json, """{"type":"Boolean"}""")
|
||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
||||
|
||||
cut.Find("select[aria-label='Return type']").Change("");
|
||||
|
||||
Assert.Null(captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidJson_RendersStartFreshButton()
|
||||
{
|
||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, "not valid json"));
|
||||
|
||||
Assert.Contains("alert-warning", cut.Markup);
|
||||
Assert.Contains("Start fresh", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonObjectJson_RendersExpectedObjectError()
|
||||
{
|
||||
var cut = Render<ReturnTypeEditor>(p => p.Add(c => c.Json, """["array","not","object"]"""));
|
||||
|
||||
Assert.Contains("Expected a JSON object", cut.Markup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
public class SchemaBuilderModelTests
|
||||
{
|
||||
// ── Parse ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_Empty_ReturnsFallback()
|
||||
{
|
||||
var fallback = SchemaBuilderModel.NewObject();
|
||||
Assert.Same(fallback, SchemaBuilderModel.Parse(null, fallback));
|
||||
Assert.Same(fallback, SchemaBuilderModel.Parse("", fallback));
|
||||
Assert.Same(fallback, SchemaBuilderModel.Parse(" ", fallback));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Malformed_ReturnsFallback()
|
||||
{
|
||||
var fallback = SchemaBuilderModel.NewObject();
|
||||
Assert.Same(fallback, SchemaBuilderModel.Parse("{not json", fallback));
|
||||
Assert.Same(fallback, SchemaBuilderModel.Parse("42", fallback));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ObjectSchema_ExtractsPropertiesAndRequired()
|
||||
{
|
||||
const string json = """
|
||||
{"type":"object","properties":{
|
||||
"id":{"type":"integer"},
|
||||
"label":{"type":"string"},
|
||||
"active":{"type":"boolean"}
|
||||
},"required":["id","active"]}
|
||||
""";
|
||||
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
|
||||
|
||||
Assert.Equal("object", node.Type);
|
||||
Assert.Collection(node.Properties,
|
||||
p => { Assert.Equal("id", p.Name); Assert.Equal("integer", p.Schema.Type); Assert.True(p.Required); },
|
||||
p => { Assert.Equal("label", p.Name); Assert.Equal("string", p.Schema.Type); Assert.False(p.Required); },
|
||||
p => { Assert.Equal("active", p.Name); Assert.Equal("boolean", p.Schema.Type); Assert.True(p.Required); });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ArrayOfPrimitive_PreservesItemType()
|
||||
{
|
||||
var node = SchemaBuilderModel.Parse(
|
||||
@"{""type"":""array"",""items"":{""type"":""integer""}}",
|
||||
SchemaBuilderModel.NewValue());
|
||||
|
||||
Assert.Equal("array", node.Type);
|
||||
Assert.NotNull(node.Items);
|
||||
Assert.Equal("integer", node.Items!.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_LegacyFlatArray_TranslatedToObjectSchema()
|
||||
{
|
||||
const string json = """[{"name":"x","type":"Integer"},{"name":"y","type":"String","required":false}]""";
|
||||
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
|
||||
|
||||
Assert.Equal("object", node.Type);
|
||||
Assert.Collection(node.Properties,
|
||||
p => { Assert.Equal("x", p.Name); Assert.Equal("integer", p.Schema.Type); Assert.True(p.Required); },
|
||||
p => { Assert.Equal("y", p.Name); Assert.Equal("string", p.Schema.Type); Assert.False(p.Required); });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NestedObjects_Recurses()
|
||||
{
|
||||
const string json = """
|
||||
{"type":"object","properties":{
|
||||
"outer":{"type":"object","properties":{
|
||||
"inner":{"type":"integer"}
|
||||
},"required":["inner"]}
|
||||
}}
|
||||
""";
|
||||
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
|
||||
|
||||
var outer = Assert.Single(node.Properties);
|
||||
Assert.Equal("outer", outer.Name);
|
||||
Assert.Equal("object", outer.Schema.Type);
|
||||
var inner = Assert.Single(outer.Schema.Properties);
|
||||
Assert.Equal("inner", inner.Name);
|
||||
Assert.Equal("integer", inner.Schema.Type);
|
||||
Assert.True(inner.Required);
|
||||
}
|
||||
|
||||
// ── Serialize ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Serialize_EmptyObject_OmitsRequired()
|
||||
{
|
||||
var node = new SchemaNode { Type = "object" };
|
||||
var json = SchemaBuilderModel.Serialize(node);
|
||||
Assert.Equal("""{"type":"object","properties":{}}""", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ObjectWithMixedRequired_EmitsOnlyRequiredNames()
|
||||
{
|
||||
var node = new SchemaNode { Type = "object" };
|
||||
node.Properties.Add(new SchemaProperty { Name = "id", Required = true, Schema = new SchemaNode { Type = "integer" } });
|
||||
node.Properties.Add(new SchemaProperty { Name = "label", Required = false, Schema = new SchemaNode { Type = "string" } });
|
||||
|
||||
var json = SchemaBuilderModel.Serialize(node);
|
||||
Assert.Equal(
|
||||
"""{"type":"object","properties":{"id":{"type":"integer"},"label":{"type":"string"}},"required":["id"]}""",
|
||||
json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_Array_IncludesItems()
|
||||
{
|
||||
var node = new SchemaNode { Type = "array", Items = new SchemaNode { Type = "string" } };
|
||||
Assert.Equal("""{"type":"array","items":{"type":"string"}}""", SchemaBuilderModel.Serialize(node));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_PropertiesWithBlankName_Skipped()
|
||||
{
|
||||
var node = new SchemaNode { Type = "object" };
|
||||
node.Properties.Add(new SchemaProperty { Name = "", Schema = new SchemaNode { Type = "integer" } });
|
||||
node.Properties.Add(new SchemaProperty { Name = "valid", Schema = new SchemaNode { Type = "string" } });
|
||||
|
||||
var json = SchemaBuilderModel.Serialize(node);
|
||||
Assert.Equal("""{"type":"object","properties":{"valid":{"type":"string"}},"required":["valid"]}""", json);
|
||||
}
|
||||
|
||||
// ── Round-trip ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_Parse_Then_Serialize_Stable()
|
||||
{
|
||||
const string original = """{"type":"object","properties":{"id":{"type":"integer"},"tags":{"type":"array","items":{"type":"string"}}},"required":["id"]}""";
|
||||
var node = SchemaBuilderModel.Parse(original, SchemaBuilderModel.NewObject());
|
||||
var roundTripped = SchemaBuilderModel.Serialize(node);
|
||||
Assert.Equal(original, roundTripped);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user