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; /// /// M9-T30: ParameterValueForm renders typed nested inputs for object and /// list parameters (replacing the raw-JSON textarea fallback), driven by the /// declared schema (with {"$ref":"lib:Name"} resolved via /// ), 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 /// Dictionary<string,object?>, lists as List<object?>). /// public class ParameterValueFormTests : BunitContext { private readonly ISchemaLibraryQueryService _library = Substitute.For(); public ParameterValueFormTests() { // Default: empty library (no $ref entries). Individual tests override. _library.GetSchemaMapAsync(Arg.Any()) .Returns(new Dictionary()); 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(p => p .Add(x => x.ParameterDefinitions, ObjectParamSchema) .Add(x => x.Values, new Dictionary())); // 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? emitted = null; var cut = Render(p => p .Add(x => x.ParameterDefinitions, ObjectParamSchema) .Add(x => x.Values, new Dictionary()) .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>(emitted!["order"]); Assert.Equal(42L, order["id"]); Assert.Equal("widget", order["name"]); } [Fact] public void ListParameter_RendersAddRemoveTypedItemRows() { Dictionary? emitted = null; var cut = Render(p => p .Add(x => x.ParameterDefinitions, ListParamSchema) .Add(x => x.Values, new Dictionary()) .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>(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>(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()) .Returns(new Dictionary { ["Address"] = libSchema }); const string paramSchema = """ { "type": "object", "properties": { "shipTo": { "$ref": "lib:Address" } } } """; var cut = Render(p => p .Add(x => x.ParameterDefinitions, paramSchema) .Add(x => x.Values, new Dictionary())); // 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(p => p .Add(x => x.ParameterDefinitions, ObjectParamSchema) .Add(x => x.Values, new Dictionary())); 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")); } }