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")); } // M1 — top-level $ref: empty-state guard must key off resolved fields, not _shapes [Fact] public void TopLevelRef_ResolvesToObject_RendersFields_NotEmptyState() { // Library entry "SomeObject" resolves to an object with two fields. const string libSchema = """ { "type": "object", "properties": { "code": { "type": "integer" }, "label": { "type": "string" } }, "required": ["code"] } """; _library.GetSchemaMapAsync(Arg.Any()) .Returns(new Dictionary { ["SomeObject"] = libSchema }); // The parameter definitions IS a top-level $ref — no "properties" key at the // root level, so _shapes (JsonSchemaShapeParser) stays empty. const string paramSchema = """{"$ref":"lib:SomeObject"}"""; var cut = Render(p => p .Add(x => x.ParameterDefinitions, paramSchema) .Add(x => x.Values, new Dictionary())); // Must NOT show the empty-state message. Assert.DoesNotContain("No parameters declared", cut.Markup); // Must render the resolved object's two fields. Assert.Contains("code", cut.Markup); Assert.Contains("label", cut.Markup); // Integer and String inputs are present. Assert.NotEmpty(cut.FindAll("input[type=number]")); Assert.NotEmpty(cut.FindAll("input[type=text]")); } // I1 — list item removal: in-progress raw text of later items must SHIFT, not wipe. // Uses a list-of-objects schema so sub-field raw-text entries (e.g. "rows[1].qty") // persist in the rendered list (the LIST SLOT stays; only the nested field value // is cleared on invalid input, not the containing dict). This is the scenario the // ShiftDict fix is designed for: "rows[1].qty" → "rows[0].qty" when rows[0] is // removed, so the error/raw-text follows the item rather than being wiped. [Fact] public void ListItemRemoval_ShiftsNestedRawTextOfLaterItems_NotWiped() { // List of objects, each with an integer "qty" field. const string schema = """ { "type": "object", "properties": { "rows": { "type": "array", "items": { "type": "object", "properties": { "qty": { "type": "integer" } } } } } } """; var cut = Render(p => p .Add(x => x.ParameterDefinitions, schema) .Add(x => x.Values, new Dictionary()) .Add(x => x.ValuesChanged, _ => { })); // Add two object items. var addButton = cut.Find("button.param-list-add"); addButton.Click(); // rows[0] addButton.Click(); // rows[1] // Commit a valid qty in rows[0]. var numberInputs = cut.FindAll("input[type=number]"); numberInputs[0].Input("5"); // Enter an INVALID qty in rows[1] — parks raw text "abc" in _rawText["rows[1].qty"] // and _parseErrors["rows[1].qty"]. The LIST SLOT rows[1] is preserved (only the // nested field value is cleared, not the containing dict). numberInputs = cut.FindAll("input[type=number]"); numberInputs[1].Input("abc"); // The error for rows[1].qty is visible before removal. Assert.NotEmpty(cut.FindAll(".text-danger")); // Remove rows[0]. With the fix, "rows[1].qty" raw-text/error entries shift to // "rows[0].qty", following the item. Without the fix they are wiped and the // item renders as if freshly added (no error). cut.Find("button.param-list-remove").Click(); // After removal, the remaining item (formerly rows[1]) must still show the // invalid-field error. Two remove buttons collapsed to one; one item renders. Assert.Single(cut.FindAll("button.param-list-remove")); Assert.NotEmpty(cut.FindAll(".text-danger")); } }