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