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.
This commit is contained in:
@@ -8,6 +8,12 @@
|
||||
<button class="btn btn-link btn-sm p-0 ms-2" type="button" @onclick="StartFresh">Start fresh</button>
|
||||
</div>
|
||||
}
|
||||
@if (_normalized)
|
||||
{
|
||||
<div class="alert alert-info py-2 small mb-2">
|
||||
Some parameter types were normalized to the current type set. Save to persist the canonical form.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_rows.Count > 0)
|
||||
{
|
||||
@@ -87,6 +93,7 @@ else if (_parseError == null)
|
||||
|
||||
private List<ParamRow> _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);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
<button class="btn btn-link btn-sm p-0 ms-2" type="button" @onclick="StartFresh">Start fresh</button>
|
||||
</div>
|
||||
}
|
||||
@if (_normalized)
|
||||
{
|
||||
<div class="alert alert-info py-2 small mb-2">
|
||||
Return type was normalized to the current type set. Save to persist the canonical form.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ParameterListEditor>(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<ParameterListEditor>(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<ParameterListEditor>(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<ParameterListEditor>(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<ParameterListEditor>(p => p.Add(c => c.Json, json));
|
||||
|
||||
Assert.DoesNotContain("alert-info", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddParameter_EmitsJsonWithNewRow()
|
||||
{
|
||||
string? captured = null;
|
||||
var cut = Render<ParameterListEditor>(p => p
|
||||
.Add(c => c.Json, (string?)null)
|
||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(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<ParameterListEditor>(p => p
|
||||
.Add(c => c.Json, json)
|
||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(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<ParameterListEditor>(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<ParameterListEditor>(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<ParameterListEditor>(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<ParameterListEditor>(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<ParameterListEditor>(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<ParameterListEditor>(p => p.Add(c => c.Json, """{"not":"an array"}"""));
|
||||
|
||||
Assert.Contains("Expected a JSON array", cut.Markup);
|
||||
}
|
||||
}
|
||||
124
tests/ScadaLink.CentralUI.Tests/Shared/ReturnTypeEditorTests.cs
Normal file
124
tests/ScadaLink.CentralUI.Tests/Shared/ReturnTypeEditorTests.cs
Normal file
@@ -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<ReturnTypeEditor>(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<ReturnTypeEditor>(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<ReturnTypeEditor>(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<ReturnTypeEditor>(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<ReturnTypeEditor>(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<ReturnTypeEditor>(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<ReturnTypeEditor>(p => p
|
||||
.Add(c => c.Json, (string?)null)
|
||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(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<ReturnTypeEditor>(p => p
|
||||
.Add(c => c.Json, (string?)null)
|
||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(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<ReturnTypeEditor>(p => p
|
||||
.Add(c => c.Json, """{"type":"Boolean"}""")
|
||||
.Add(c => c.JsonChanged, EventCallback.Factory.Create<string?>(this, v => captured = v)));
|
||||
|
||||
cut.Find("select[aria-label='Return type']").Change("");
|
||||
|
||||
Assert.Null(captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidJson_RendersStartFreshButton()
|
||||
{
|
||||
var cut = Render<ReturnTypeEditor>(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<ReturnTypeEditor>(p => p.Add(c => c.Json, """["array","not","object"]"""));
|
||||
|
||||
Assert.Contains("Expected a JSON object", cut.Markup);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user