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