fix(central-ui): resolve CentralUI-015..019 — pager windowing, logout CSRF, narrowed catch blocks, coverage; CentralUI-015 re-triaged Won't Fix
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-016. <c>DataTable</c> looped
|
||||
/// <c>for i = 1..totalPages</c> and emitted one numbered <c><li></c>
|
||||
/// button per page; a few thousand records at page size 25 rendered hundreds
|
||||
/// of buttons into the diff on every state change. The fix windows the pager
|
||||
/// so only first / last / a small range around the current page render.
|
||||
/// </summary>
|
||||
public class DataTablePagerTests : BunitContext
|
||||
{
|
||||
private IRenderedComponent<DataTable<int>> RenderTable(int itemCount, int pageSize = 25)
|
||||
{
|
||||
return Render<DataTable<int>>(parameters => parameters
|
||||
.Add(p => p.Items, Enumerable.Range(1, itemCount).ToList())
|
||||
.Add(p => p.PageSize, pageSize)
|
||||
.Add(p => p.ShowSearch, false)
|
||||
.Add(p => p.HeaderContent, (RenderFragment)(b => b.AddMarkupContent(0, "<th>N</th>")))
|
||||
.Add(p => p.RowContent, (RenderFragment<int>)(item => b => b.AddMarkupContent(0, $"<tr><td>{item}</td></tr>"))));
|
||||
}
|
||||
|
||||
private static int NumberedPageButtons(IRenderedComponent<DataTable<int>> cut)
|
||||
=> cut.FindAll("ul.pagination li.page-item button")
|
||||
.Count(b => int.TryParse(b.TextContent.Trim(), out _));
|
||||
|
||||
[Fact]
|
||||
public void Pager_WithThousandsOfPages_RendersWindowedNotEveryPage()
|
||||
{
|
||||
// 5000 items / 25 = 200 pages. The pre-fix pager rendered 200 numbered
|
||||
// buttons; the windowed pager renders at most a dozen.
|
||||
var cut = RenderTable(itemCount: 5000);
|
||||
|
||||
var numbered = NumberedPageButtons(cut);
|
||||
|
||||
Assert.True(numbered <= 12,
|
||||
$"Expected a windowed pager (<= 12 numbered buttons) but rendered {numbered}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pager_SmallDataset_StillRendersEveryPage()
|
||||
{
|
||||
// 5 pages — small enough to render all numbered buttons (no windowing harm).
|
||||
var cut = RenderTable(itemCount: 125);
|
||||
|
||||
var numbered = NumberedPageButtons(cut);
|
||||
|
||||
Assert.Equal(5, numbered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pager_WindowedAroundCurrentPage_AlwaysIncludesFirstAndLast()
|
||||
{
|
||||
var cut = RenderTable(itemCount: 5000); // 200 pages
|
||||
|
||||
var numbered = cut.FindAll("ul.pagination li.page-item button")
|
||||
.Select(b => b.TextContent.Trim())
|
||||
.Where(t => int.TryParse(t, out _))
|
||||
.ToList();
|
||||
|
||||
// First and last page are always reachable from the windowed pager.
|
||||
Assert.Contains("1", numbered);
|
||||
Assert.Contains("200", numbered);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization tests for CentralUI-015 (re-triaged Won't Fix — see
|
||||
/// findings.md). The finding claimed <c>ContinueWith(..., TaskScheduler.Default)</c>
|
||||
/// made callers resume off the render thread; that premise is incorrect — an
|
||||
/// <c>await</c> always resumes on the awaiter's own captured
|
||||
/// <see cref="SynchronizationContext"/> regardless of where the awaited task
|
||||
/// completes. <c>ConfirmAsync_AwaiterResumesOnItsCapturedSyncContext</c> pins
|
||||
/// that correct behaviour (it passes against both the old <c>ContinueWith</c>
|
||||
/// form and the current inline-projection form). The remaining tests pin the
|
||||
/// dialog result-resolution contract.
|
||||
/// </summary>
|
||||
public class DialogServiceThreadingTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A single-threaded sync context that records every posted callback —
|
||||
/// stands in for the Blazor renderer's dispatcher.
|
||||
/// </summary>
|
||||
private sealed class TrackingSyncContext : SynchronizationContext
|
||||
{
|
||||
private readonly Thread _thread;
|
||||
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = new();
|
||||
public int PostedCount;
|
||||
|
||||
public TrackingSyncContext()
|
||||
{
|
||||
_thread = new Thread(() =>
|
||||
{
|
||||
SetSynchronizationContext(this);
|
||||
foreach (var (cb, st) in _queue.GetConsumingEnumerable())
|
||||
{
|
||||
cb(st);
|
||||
}
|
||||
}) { IsBackground = true };
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
public override void Post(SendOrPostCallback d, object? state)
|
||||
{
|
||||
Interlocked.Increment(ref PostedCount);
|
||||
_queue.Add((d, state));
|
||||
}
|
||||
|
||||
public void Complete() => _queue.CompleteAdding();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmAsync_AwaiterResumesOnItsCapturedSyncContext()
|
||||
{
|
||||
var service = new DialogService();
|
||||
var ctx = new TrackingSyncContext();
|
||||
|
||||
// Run the awaiting "component" code on the tracking context.
|
||||
var done = new TaskCompletionSource<int>();
|
||||
ctx.Post(async void (_) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var task = service.ConfirmAsync("t", "m");
|
||||
// Resolve from another thread, mimicking the host dispatching.
|
||||
_ = Task.Run(() => service.Resolve(true));
|
||||
await task;
|
||||
// The continuation after the await must be back on the tracking
|
||||
// context's single thread.
|
||||
done.SetResult(Environment.CurrentManagedThreadId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
done.SetException(ex);
|
||||
}
|
||||
}, null);
|
||||
|
||||
var resumeThreadId = await done.Task;
|
||||
ctx.Complete();
|
||||
|
||||
// The continuation was posted to (and ran on) the captured context.
|
||||
Assert.True(ctx.PostedCount >= 1,
|
||||
"ConfirmAsync continuation must post back to the caller's SynchronizationContext.");
|
||||
Assert.NotEqual(Environment.CurrentManagedThreadId, resumeThreadId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmAsync_ResolvesWithExpectedValue()
|
||||
{
|
||||
var service = new DialogService();
|
||||
|
||||
var task = service.ConfirmAsync("t", "m");
|
||||
service.Resolve(true);
|
||||
|
||||
Assert.True(await task);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PromptAsync_ResolvesWithExpectedValue()
|
||||
{
|
||||
var service = new DialogService();
|
||||
|
||||
var task = service.PromptAsync("t", "label");
|
||||
service.Resolve("typed value");
|
||||
|
||||
Assert.Equal("typed value", await task);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PromptAsync_CancelledResolvesToNull()
|
||||
{
|
||||
var service = new DialogService();
|
||||
|
||||
var task = service.PromptAsync("t", "label");
|
||||
service.Resolve(null);
|
||||
|
||||
Assert.Null(await task);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Bunit;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-018. <c>MonacoEditor</c> wrapped every JS
|
||||
/// interop call in a bare <c>try { ... } catch { }</c> with no logging — a
|
||||
/// genuine Monaco init failure became invisible. The fix narrows the catch to
|
||||
/// the expected prerender / disconnect cases and logs any real
|
||||
/// <see cref="JSException"/> via <c>ILogger</c>.
|
||||
/// </summary>
|
||||
public class MonacoEditorLoggingTests : BunitContext
|
||||
{
|
||||
/// <summary>Captures log entries so the test can assert on them.</summary>
|
||||
private sealed class CapturingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries);
|
||||
public void Dispose() { }
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
private readonly List<(LogLevel, string, Exception?)> _entries;
|
||||
public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries;
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
|
||||
Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
=> _entries.Add((logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateEditor_GenuineJsException_IsLogged_NotSwallowed()
|
||||
{
|
||||
var provider = new CapturingLoggerProvider();
|
||||
Services.AddLogging(b => b.AddProvider(provider));
|
||||
|
||||
// createEditor is an InvokeVoidAsync call — configure it to throw a
|
||||
// genuine JSException so we exercise the real-failure path.
|
||||
JSInterop.Mode = JSRuntimeMode.Strict;
|
||||
JSInterop.SetupVoid("MonacoBlazor.createEditor", _ => true)
|
||||
.SetException(new JSException("Monaco failed to load"));
|
||||
|
||||
// Pre-fix: the bare catch {} swallowed this with no trace. Post-fix:
|
||||
// the component renders fine but the failure is logged.
|
||||
var cut = Render<MonacoEditor>(p => p.Add(c => c.ShowToolbar, false));
|
||||
|
||||
var errors = provider.Entries.Where(e => e.Level == LogLevel.Error).ToList();
|
||||
Assert.NotEmpty(errors);
|
||||
Assert.Contains(errors, e => e.Exception is JSException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateEditor_Prerender_DoesNotLog()
|
||||
{
|
||||
// When JS interop is unavailable (prerender), createEditor throws
|
||||
// InvalidOperationException — that is expected and must NOT be logged.
|
||||
var provider = new CapturingLoggerProvider();
|
||||
Services.AddLogging(b => b.AddProvider(provider));
|
||||
|
||||
JSInterop.Mode = JSRuntimeMode.Strict;
|
||||
JSInterop.SetupVoid("MonacoBlazor.createEditor", _ => true)
|
||||
.SetException(new InvalidOperationException("JS interop not available during prerender"));
|
||||
|
||||
var cut = Render<MonacoEditor>(p => p.Add(c => c.ShowToolbar, false));
|
||||
|
||||
Assert.DoesNotContain(provider.Entries, e => e.Level >= LogLevel.Warning);
|
||||
}
|
||||
}
|
||||
67
tests/ScadaLink.CentralUI.Tests/Shared/PagerWindowTests.cs
Normal file
67
tests/ScadaLink.CentralUI.Tests/Shared/PagerWindowTests.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="PagerWindow"/> helper introduced for
|
||||
/// CentralUI-016 — windowed pagination that keeps the rendered button count
|
||||
/// bounded regardless of total page count.
|
||||
/// </summary>
|
||||
public class PagerWindowTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_SmallPageCount_ReturnsEveryPage_NoEllipsis()
|
||||
{
|
||||
var pages = PagerWindow.Build(currentPage: 3, totalPages: 5);
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, pages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_LargePageCount_IsBounded_AndIncludesFirstAndLast()
|
||||
{
|
||||
var pages = PagerWindow.Build(currentPage: 100, totalPages: 200);
|
||||
|
||||
Assert.Contains(1, pages);
|
||||
Assert.Contains(200, pages);
|
||||
Assert.Contains(100, pages);
|
||||
// First, ellipsis, window of 5, ellipsis, last — never the full 200.
|
||||
Assert.True(pages.Count <= 12, $"Expected a bounded window but got {pages.Count} entries.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_LargePageCount_InsertsEllipsisForGaps()
|
||||
{
|
||||
// 0 is the ellipsis sentinel.
|
||||
var pages = PagerWindow.Build(currentPage: 100, totalPages: 200);
|
||||
|
||||
Assert.Contains(0, pages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CurrentNearStart_NoLeadingEllipsis()
|
||||
{
|
||||
var pages = PagerWindow.Build(currentPage: 1, totalPages: 200);
|
||||
|
||||
// Pages 1..3 are contiguous from the start, so no ellipsis before them.
|
||||
Assert.Equal(1, pages[0]);
|
||||
Assert.NotEqual(0, pages[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ClampsOutOfRangeCurrentPage()
|
||||
{
|
||||
var pages = PagerWindow.Build(currentPage: 999, totalPages: 200);
|
||||
|
||||
Assert.Contains(200, pages);
|
||||
Assert.True(pages.Count <= 12);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-3)]
|
||||
public void Build_NonPositiveTotalPages_ReturnsEmpty(int totalPages)
|
||||
{
|
||||
Assert.Empty(PagerWindow.Build(currentPage: 1, totalPages: totalPages));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-018. <c>TreeView</c>'s storage-restore path
|
||||
/// called <c>JsonSerializer.Deserialize</c> on the raw <c>treeviewStorage</c>
|
||||
/// payload outside any try block — a corrupt payload threw an uncaught
|
||||
/// <c>JsonException</c> during <c>OnAfterRenderAsync</c>, breaking the
|
||||
/// component. The fix guards the deserialize and ignores a corrupt payload.
|
||||
/// </summary>
|
||||
public class TreeViewStorageResilienceTests : BunitContext
|
||||
{
|
||||
private record TestNode(string Key, string Label, List<TestNode> Children);
|
||||
|
||||
private static List<TestNode> Roots() => new()
|
||||
{
|
||||
new("a", "Alpha", new() { new("a1", "Alpha-1", new()) }),
|
||||
new("b", "Beta", new()),
|
||||
};
|
||||
|
||||
private IRenderedComponent<TreeView<TestNode>> BuildTree()
|
||||
=> Render<TreeView<TestNode>>(parameters => parameters
|
||||
.Add(p => p.Items, Roots())
|
||||
.Add(p => p.ChildrenSelector, n => n.Children)
|
||||
.Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
|
||||
.Add(p => p.KeySelector, n => n.Key)
|
||||
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => b =>
|
||||
b.AddMarkupContent(0, $"<span>{node.Label}</span>")))
|
||||
.Add(p => p.StorageKey, "corrupt-tree"));
|
||||
|
||||
[Fact]
|
||||
public void StorageRestore_CorruptJsonPayload_DoesNotThrow_AndStillRenders()
|
||||
{
|
||||
// A garbage payload that is not valid JSON for a List<string>.
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
|
||||
.SetResult("{not json at all]");
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
|
||||
// Pre-fix: OnAfterRenderAsync threw JsonException out of the unguarded
|
||||
// Deserialize call. Post-fix: the corrupt payload is ignored.
|
||||
var cut = BuildTree();
|
||||
|
||||
Assert.Contains("Alpha", cut.Markup);
|
||||
Assert.Contains("Beta", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StorageRestore_WrongShapeJson_DoesNotThrow()
|
||||
{
|
||||
// Valid JSON, but not a List<string> — an object, not an array.
|
||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
|
||||
.SetResult("{\"unexpected\": true}");
|
||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||
|
||||
var cut = BuildTree();
|
||||
|
||||
Assert.Contains("Alpha", cut.Markup);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user