From e667ea2b50e3051396c812e2a89d0e37695eb8bf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 04:27:00 -0400 Subject: [PATCH] test(ui/design): roundtrip tests + normalization notice for IO editors Editors now set a _normalized flag when ParseFromJson coalesces a legacy type name (lowercase "string", "Int32", "Double", etc.) to the canonical set. When flagged, render a small alert-info inline: "Some parameter types were normalized... Save to persist the canonical form." The flag clears on any user edit so the notice doesn't linger after Emit overwrites the JSON. 31 new bUnit tests in tests/.../Shared/: - ParameterListEditorTests: null/empty rendering, row count per JSON entry, legacy type normalization across .NET names + lowercase, the normalized notice trigger, add/remove emission, List/non-List item-type column visibility, required-flag round trip, invalid JSON + non-array error paths. - ReturnTypeEditorTests: null vs simple vs List shape, legacy type normalization, change-type / clear-type emission, invalid JSON + non-object error paths. Total CentralUI test count: 82 -> 113. --- .../Shared/ParameterListEditor.razor | 19 ++- .../Components/Shared/ReturnTypeEditor.razor | 19 ++- .../Shared/ParameterListEditorTests.cs | 150 ++++++++++++++++++ .../Shared/ReturnTypeEditorTests.cs | 124 +++++++++++++++ 4 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 tests/ScadaLink.CentralUI.Tests/Shared/ParameterListEditorTests.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Shared/ReturnTypeEditorTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Shared/ParameterListEditor.razor b/src/ScadaLink.CentralUI/Components/Shared/ParameterListEditor.razor index 88a10e7..c8f3b74 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/ParameterListEditor.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/ParameterListEditor.razor @@ -8,6 +8,12 @@ } +@if (_normalized) +{ +
+ Some parameter types were normalized to the current type set. Save to persist the canonical form. +
+} @if (_rows.Count > 0) { @@ -87,6 +93,7 @@ else if (_parseError == null) private List _rows = new(); private string? _parseError; + private bool _normalized; private string? _lastSeenJson; protected override void OnParametersSet() @@ -101,6 +108,7 @@ else if (_parseError == null) private void ParseFromJson() { _parseError = null; + _normalized = false; _rows = new(); if (string.IsNullOrWhiteSpace(Json)) return; @@ -119,11 +127,17 @@ else if (_parseError == null) var rawType = el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String"; var rawItem = el.TryGetProperty("itemType", out var it) ? it.GetString() ?? "String" : "String"; var required = !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False; + var normType = NormalizeType(rawType); + var normItem = NormalizeType(rawItem); + if (normType != rawType || (rawType == "List" && normItem != rawItem)) + { + _normalized = true; + } _rows.Add(new ParamRow { Name = name, - Type = NormalizeType(rawType), - ItemType = NormalizeType(rawItem), + Type = normType, + ItemType = normItem, Required = required }); } @@ -172,6 +186,7 @@ else if (_parseError == null) { var json = SerializeToJson(); _lastSeenJson = json; + _normalized = false; await JsonChanged.InvokeAsync(json); } diff --git a/src/ScadaLink.CentralUI/Components/Shared/ReturnTypeEditor.razor b/src/ScadaLink.CentralUI/Components/Shared/ReturnTypeEditor.razor index 1792500..5c9d524 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/ReturnTypeEditor.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/ReturnTypeEditor.razor @@ -8,6 +8,12 @@ } +@if (_normalized) +{ +
+ Return type was normalized to the current type set. Save to persist the canonical form. +
+}
@@ -44,6 +50,7 @@ private string _type = ""; private string _itemType = "String"; private string? _parseError; + private bool _normalized; private string? _lastSeenJson; protected override void OnParametersSet() @@ -58,6 +65,7 @@ private void ParseFromJson() { _parseError = null; + _normalized = false; _type = ""; _itemType = "String"; if (string.IsNullOrWhiteSpace(Json)) return; @@ -70,8 +78,14 @@ _parseError = "Expected a JSON object with a type field."; return; } - _type = doc.RootElement.TryGetProperty("type", out var t) ? NormalizeType(t.GetString() ?? "") : ""; - _itemType = doc.RootElement.TryGetProperty("itemType", out var it) ? NormalizeType(it.GetString() ?? "String") : "String"; + var rawType = doc.RootElement.TryGetProperty("type", out var t) ? t.GetString() ?? "" : ""; + var rawItem = doc.RootElement.TryGetProperty("itemType", out var it) ? it.GetString() ?? "String" : "String"; + _type = NormalizeType(rawType); + _itemType = NormalizeType(rawItem); + if (_type != rawType || (rawType == "List" && _itemType != rawItem)) + { + _normalized = true; + } } catch (JsonException ex) { @@ -112,6 +126,7 @@ json = JsonSerializer.Serialize(obj); } _lastSeenJson = json; + _normalized = false; await JsonChanged.InvokeAsync(json); } } diff --git a/tests/ScadaLink.CentralUI.Tests/Shared/ParameterListEditorTests.cs b/tests/ScadaLink.CentralUI.Tests/Shared/ParameterListEditorTests.cs new file mode 100644 index 0000000..b3725b5 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Shared/ParameterListEditorTests.cs @@ -0,0 +1,150 @@ +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(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(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(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(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(p => p.Add(c => c.Json, json)); + + Assert.DoesNotContain("alert-info", cut.Markup); + } + + [Fact] + public void AddParameter_EmitsJsonWithNewRow() + { + string? captured = null; + var cut = Render(p => p + .Add(c => c.Json, (string?)null) + .Add(c => c.JsonChanged, EventCallback.Factory.Create(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(p => p + .Add(c => c.Json, json) + .Add(c => c.JsonChanged, EventCallback.Factory.Create(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(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(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(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(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(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(p => p.Add(c => c.Json, """{"not":"an array"}""")); + + Assert.Contains("Expected a JSON array", cut.Markup); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Shared/ReturnTypeEditorTests.cs b/tests/ScadaLink.CentralUI.Tests/Shared/ReturnTypeEditorTests.cs new file mode 100644 index 0000000..ee324c3 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Shared/ReturnTypeEditorTests.cs @@ -0,0 +1,124 @@ +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(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(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(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(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(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(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(p => p + .Add(c => c.Json, (string?)null) + .Add(c => c.JsonChanged, EventCallback.Factory.Create(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(p => p + .Add(c => c.Json, (string?)null) + .Add(c => c.JsonChanged, EventCallback.Factory.Create(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(p => p + .Add(c => c.Json, """{"type":"Boolean"}""") + .Add(c => c.JsonChanged, EventCallback.Factory.Create(this, v => captured = v))); + + cut.Find("select[aria-label='Return type']").Change(""); + + Assert.Null(captured); + } + + [Fact] + public void InvalidJson_RendersStartFreshButton() + { + var cut = Render(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(p => p.Add(c => c.Json, """["array","not","object"]""")); + + Assert.Contains("Expected a JSON object", cut.Markup); + } +}