feat(m9/T30): schema-driven nested object/list value-entry forms (+ $ref resolution)

This commit is contained in:
Joseph Doherty
2026-06-18 12:40:22 -04:00
parent 991d90a875
commit 10c08dd309
2 changed files with 668 additions and 76 deletions
@@ -0,0 +1,187 @@
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// M9-T30: <c>ParameterValueForm</c> renders typed nested inputs for object and
/// list parameters (replacing the raw-JSON textarea fallback), driven by the
/// declared schema (with <c>{"$ref":"lib:Name"}</c> resolved via
/// <see cref="ISchemaLibraryQueryService"/>), with per-field validation, and
/// emits the SAME canonical JSON value shape the form has always produced
/// (scalars as bool/long/double/string, objects as
/// <c>Dictionary&lt;string,object?&gt;</c>, lists as <c>List&lt;object?&gt;</c>).
/// </summary>
public class ParameterValueFormTests : BunitContext
{
private readonly ISchemaLibraryQueryService _library = Substitute.For<ISchemaLibraryQueryService>();
public ParameterValueFormTests()
{
// Default: empty library (no $ref entries). Individual tests override.
_library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, string>());
Services.AddSingleton(_library);
}
private const string ObjectParamSchema = """
{
"type": "object",
"properties": {
"order": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
},
"required": ["id"]
}
},
"required": ["order"]
}
""";
private const string ListParamSchema = """
{
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": { "type": "string" }
}
}
}
""";
[Fact]
public void ObjectParameter_RendersPerFieldInputs_NotRawTextarea()
{
var cut = Render<ParameterValueForm>(p => p
.Add(x => x.ParameterDefinitions, ObjectParamSchema)
.Add(x => x.Values, new Dictionary<string, object?>()));
// No raw-JSON textarea fallback for the object parameter.
Assert.Empty(cut.FindAll("textarea"));
// Nested fields are rendered as their own typed inputs: an Integer (number)
// for order.id and a String (text) for order.name.
var numberInputs = cut.FindAll("input[type=number]");
var textInputs = cut.FindAll("input[type=text]");
Assert.NotEmpty(numberInputs);
Assert.NotEmpty(textInputs);
// The nested field labels surface.
Assert.Contains("id", cut.Markup);
Assert.Contains("name", cut.Markup);
}
[Fact]
public void ObjectParameter_RoundTripsToCanonicalNestedJsonShape()
{
Dictionary<string, object?>? emitted = null;
var cut = Render<ParameterValueForm>(p => p
.Add(x => x.ParameterDefinitions, ObjectParamSchema)
.Add(x => x.Values, new Dictionary<string, object?>())
.Add(x => x.ValuesChanged, v => emitted = v));
// Enter the nested integer field (order.id).
var idInput = cut.FindAll("input[type=number]")[0];
idInput.Input("42");
// Enter the nested string field (order.name).
var nameInput = cut.FindAll("input[type=text]")[0];
nameInput.Input("widget");
Assert.NotNull(emitted);
var order = Assert.IsType<Dictionary<string, object?>>(emitted!["order"]);
Assert.Equal(42L, order["id"]);
Assert.Equal("widget", order["name"]);
}
[Fact]
public void ListParameter_RendersAddRemoveTypedItemRows()
{
Dictionary<string, object?>? emitted = null;
var cut = Render<ParameterValueForm>(p => p
.Add(x => x.ParameterDefinitions, ListParamSchema)
.Add(x => x.Values, new Dictionary<string, object?>())
.Add(x => x.ValuesChanged, v => emitted = v));
// No raw-JSON textarea fallback for the list parameter.
Assert.Empty(cut.FindAll("textarea"));
// An "add item" button exists; clicking it appends a typed item editor.
var addButton = cut.Find("button.param-list-add");
addButton.Click();
var itemInput = cut.Find("input.param-list-item");
itemInput.Input("alpha");
Assert.NotNull(emitted);
var tags = Assert.IsType<List<object?>>(emitted!["tags"]);
Assert.Single(tags);
Assert.Equal("alpha", tags[0]);
// Removing the row drops it back to an empty list.
cut.Find("button.param-list-remove").Click();
var tagsAfter = Assert.IsType<List<object?>>(emitted!["tags"]);
Assert.Empty(tagsAfter);
}
[Fact]
public void RefBearingParameter_RendersResolvedShape()
{
// The library entry "Address" resolves to an object schema with two string
// fields; the parameter references it via {"$ref":"lib:Address"}.
const string libSchema = """
{
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
}
}
""";
_library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, string> { ["Address"] = libSchema });
const string paramSchema = """
{
"type": "object",
"properties": {
"shipTo": { "$ref": "lib:Address" }
}
}
""";
var cut = Render<ParameterValueForm>(p => p
.Add(x => x.ParameterDefinitions, paramSchema)
.Add(x => x.Values, new Dictionary<string, object?>()));
// The resolved shape renders its fields (no raw textarea, no unresolved-ref message).
Assert.Empty(cut.FindAll("textarea"));
Assert.Contains("street", cut.Markup);
Assert.Contains("city", cut.Markup);
// Two string inputs for the resolved object's two fields.
Assert.Equal(2, cut.FindAll("input[type=text]").Count);
}
[Fact]
public void PerFieldValidationError_SurfacesInline()
{
// A required nested field (order.id is an integer) left non-numeric must
// surface a per-field error inline.
var cut = Render<ParameterValueForm>(p => p
.Add(x => x.ParameterDefinitions, ObjectParamSchema)
.Add(x => x.Values, new Dictionary<string, object?>()));
var idInput = cut.FindAll("input[type=number]")[0];
idInput.Input("not-a-number");
// A field-level error class is shown.
Assert.NotEmpty(cut.FindAll(".text-danger"));
}
}