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:
Joseph Doherty
2026-05-16 22:04:21 -04:00
parent 404216b4ee
commit d7b275fc9b
18 changed files with 772 additions and 50 deletions

View File

@@ -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>&lt;li&gt;</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);
}
}

View File

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

View File

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

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

View File

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