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:
Joseph Doherty
2026-05-13 00:33:00 -04:00
parent 57f477fd28
commit 783da8e21a
25 changed files with 3609 additions and 861 deletions

View File

@@ -0,0 +1,106 @@
using ScadaLink.CentralUI.ScriptAnalysis;
namespace ScadaLink.CentralUI.Tests.ScriptAnalysis;
public class JsonSchemaShapeParserTests
{
// ── JSON Schema (post-migration) ─────────────────────────────────────────
[Fact]
public void Parameters_JsonSchema_ScalarsAndRequired()
{
const string json = """
{"type":"object","properties":{
"id":{"type":"integer"},
"label":{"type":"string"},
"active":{"type":"boolean"}
},"required":["id","active"]}
""";
var result = JsonSchemaShapeParser.ParseParameters(json);
Assert.Collection(result,
p => { Assert.Equal("id", p.Name); Assert.Equal("Integer", p.Type); Assert.True(p.Required); },
p => { Assert.Equal("label", p.Name); Assert.Equal("String", p.Type); Assert.False(p.Required); },
p => { Assert.Equal("active", p.Name); Assert.Equal("Boolean", p.Type); Assert.True(p.Required); });
}
[Fact]
public void Parameters_JsonSchema_ArrayOfStringsBecomesListString()
{
const string json = """
{"type":"object","properties":{
"tags":{"type":"array","items":{"type":"string"}}
}}
""";
var result = JsonSchemaShapeParser.ParseParameters(json);
var tags = Assert.Single(result);
Assert.Equal("tags", tags.Name);
Assert.Equal("List<String>", tags.Type);
Assert.False(tags.Required);
}
[Fact]
public void Return_JsonSchema_Number()
{
Assert.Equal("Float", JsonSchemaShapeParser.ParseReturnType(@"{""type"":""number""}"));
}
[Fact]
public void Return_JsonSchema_ArrayOfIntegers()
{
Assert.Equal("List<Integer>",
JsonSchemaShapeParser.ParseReturnType(@"{""type"":""array"",""items"":{""type"":""integer""}}"));
}
// ── Legacy flat shape (pre-migration safety net) ─────────────────────────
[Fact]
public void Parameters_Legacy_FlatArrayStillParses()
{
const string json = """[{"name":"x","type":"Integer"},{"name":"y","type":"String","required":false}]""";
var result = JsonSchemaShapeParser.ParseParameters(json);
Assert.Collection(result,
p => { Assert.Equal("x", p.Name); Assert.Equal("Integer", p.Type); Assert.True(p.Required); },
p => { Assert.Equal("y", p.Name); Assert.Equal("String", p.Type); Assert.False(p.Required); });
}
[Fact]
public void Return_Legacy_ListSentinelStillParses()
{
Assert.Equal("List<String>",
JsonSchemaShapeParser.ParseReturnType(@"{""type"":""List"",""itemType"":""String""}"));
}
// ── Edge cases ────────────────────────────────────────────────────────────
[Fact]
public void Parameters_Null_ReturnsEmpty()
{
Assert.Empty(JsonSchemaShapeParser.ParseParameters(null));
Assert.Empty(JsonSchemaShapeParser.ParseParameters(""));
Assert.Empty(JsonSchemaShapeParser.ParseParameters(" "));
}
[Fact]
public void Parameters_Malformed_ReturnsEmpty()
{
Assert.Empty(JsonSchemaShapeParser.ParseParameters("{not json"));
Assert.Empty(JsonSchemaShapeParser.ParseParameters("42"));
}
[Fact]
public void Return_Null_ReturnsNull()
{
Assert.Null(JsonSchemaShapeParser.ParseReturnType(null));
Assert.Null(JsonSchemaShapeParser.ParseReturnType(""));
}
[Fact]
public void Parameters_SchemaWithNoProperties_ReturnsEmpty()
{
Assert.Empty(JsonSchemaShapeParser.ParseParameters(@"{""type"":""object""}"));
Assert.Empty(JsonSchemaShapeParser.ParseParameters(@"{""type"":""object"",""properties"":{}}"));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}