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