fix(adminui): Monaco text syncs on demand, diagnostics are sequenced, Problems list (D1)

R04:P1 - the editor no longer ships its full text JS->.NET on every keystroke. It reports
at blur, and ScriptEdit / the VirtualTag modal read the authoritative value with
MonacoEditor.GetValueAsync at Save (validation runs after the pull). The Blazor hub's
MaximumReceiveMessageSize is raised from 32 KB to 1 MB. The JS refuses a value that
would not fit instead of sending an oversize frame, and Save surfaces that refusal
rather than persisting stale source.

R04:P9 - diagnostics carry a generation counter and an AbortController. Every edit aborts
the in-flight request, and only the response for the current text is applied.

R04:S2 - the always-empty inlay-hints provider, endpoint, service method and contracts
are removed.

R04:S3 - MarkersChanged is typed (DiagnosticMarker) and feeds a new ScriptProblemsList.
Clicking a problem calls RevealLineAsync. Markers cross to .NET only when a list
subscribes.

Refs #745

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:38:56 -04:00
parent 8e3bbf91fb
commit da2578833d
14 changed files with 514 additions and 61 deletions
@@ -0,0 +1,80 @@
using System.Text.RegularExpressions;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// Source pins for the Monaco interop (AdminUI has no bUnit and no JS test runner). These do not prove the
/// browser behaviour — the docker-dev live check does — they stop the specific regressions R04:P1/P9/S2/S3 fixed
/// from quietly coming back: per-keystroke text sync, unsequenced diagnostics, the inlay-hints POST, and an
/// unconsumed marker round-trip.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MonacoInteropSourceTests
{
private static string Read(params string[] relative)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = dir.Parent;
dir.ShouldNotBeNull("could not locate the repository root");
return File.ReadAllText(Path.Combine(new[] { dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI" }.Concat(relative).ToArray()));
}
private static string Js() => Read("wwwroot", "js", "monaco-init.js");
[Fact]
public void The_content_change_handler_does_not_ship_the_text_to_dotnet()
{
var js = Js();
var handler = Regex.Match(js, @"onDidChangeModelContent\(function \(\) \{(?<body>.*?)\n \}\);", RegexOptions.Singleline);
handler.Success.ShouldBeTrue("the content-change handler moved; update this pin");
handler.Groups["body"].Value.ShouldNotContain("OnValueChanged");
handler.Groups["body"].Value.ShouldNotContain("getValue");
// Positive control: the text IS still reported — at blur.
js.ShouldContain("onDidBlurEditorText");
js.ShouldContain("\"OnValueChanged\"");
}
[Fact]
public void Diagnostics_are_generation_guarded_and_abortable()
{
var js = Js();
js.ShouldContain("diagGeneration");
js.ShouldContain("generation !== e.diagGeneration");
js.ShouldContain("new AbortController()");
js.ShouldContain("signal: signal");
}
[Fact]
public void Providers_honour_the_monaco_cancellation_token()
{
var js = Js();
js.ShouldContain("token.onCancellationRequested");
foreach (var path in new[] { "\"completions\"", "\"hover\"", "\"format\"", "\"signature-help\"" })
js.ShouldContain("postAnalysis(" + path);
}
[Fact]
public void The_inlay_hints_provider_is_not_registered() => Js().ShouldNotContain("registerInlayHintsProvider");
[Fact]
public void Markers_cross_to_dotnet_only_for_a_subscribed_problems_list()
{
Js().ShouldContain("if (e.reportMarkers) dotNetRef.invokeMethodAsync(\"OnMarkersChanged\"");
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("reportMarkers = MarkersChanged.HasDelegate");
}
[Theory]
[InlineData("Components", "Pages", "ScriptEdit.razor")]
[InlineData("Components", "Shared", "Uns", "VirtualTagModal.razor")]
public void Script_hosts_read_the_editor_on_save_and_render_the_problems_list(params string[] path)
{
var src = Read(path);
src.ShouldContain("GetValueAsync()");
src.ShouldContain("<ScriptProblemsList");
src.ShouldContain("RevealLineAsync(");
src.ShouldContain("MarkersChanged=");
}
}
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:P1 / S2 — the Monaco editor no longer ships its text over the circuit per keystroke; the page reads it on
/// demand at blur / Save. That single read is one hub message, so the Blazor hub's receive limit is raised from
/// the 32 KB default to 1 MB, and the JS refuses (rather than sends) a value that would not fit.
/// </summary>
public sealed class MonacoSyncTests
{
private static HubOptions ComponentHubOptions(IServiceProvider sp)
{
// ComponentHub is internal to Microsoft.AspNetCore.Components.Server; resolve its options by type name.
var hubType = Type.GetType("Microsoft.AspNetCore.Components.Server.ComponentHub, Microsoft.AspNetCore.Components.Server", throwOnError: true)!;
var optionsType = typeof(IOptions<>).MakeGenericType(typeof(HubOptions<>).MakeGenericType(hubType));
var options = sp.GetRequiredService(optionsType);
return (HubOptions)optionsType.GetProperty("Value")!.GetValue(options)!;
}
[Fact]
public void The_blazor_hub_accepts_a_1_MB_message()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddAdminUI();
using var sp = services.BuildServiceProvider();
ComponentHubOptions(sp).MaximumReceiveMessageSize.ShouldBe(1024 * 1024);
ScriptEditorLimits.HubMaximumReceiveMessageSize.ShouldBe(1024 * 1024);
}
[Fact]
public void Without_AddAdminUI_the_default_limit_is_the_32_KB_that_truncated_large_scripts()
{
// Positive control for the test above: the 1 MB comes from AddAdminUI, not from a framework default.
var services = new ServiceCollection();
services.AddLogging();
services.AddRazorComponents().AddInteractiveServerComponents();
using var sp = services.BuildServiceProvider();
(ComponentHubOptions(sp).MaximumReceiveMessageSize ?? 32 * 1024).ShouldBe(32 * 1024);
}
[Fact]
public void The_editor_sync_budget_leaves_headroom_inside_the_hub_limit_and_fits_a_100_KB_script()
{
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize);
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeGreaterThan(4 * 100 * 1024); // 100 KB even after escaping twice
}
}
@@ -0,0 +1,59 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:S3 / S2 — the marker round-trip now feeds a real Problems list (MarkersChanged → list → RevealLineAsync),
/// and the dead inlay-hints endpoint is gone. The Razor wiring is live-verified; the ordering and wording it
/// renders are pinned here.
/// </summary>
public sealed class ProblemsListTests
{
private static DiagnosticMarker M(int sev, int line, int col, string code = "X") => new(sev, line, col, line, col + 1, "msg", code);
[Fact]
public void Problems_are_ordered_errors_first_then_by_position()
{
var ordered = ScriptProblems.Order(new[]
{
M(4, 1, 1, "W1"), M(8, 9, 2, "E3"), M(8, 2, 5, "E2"), M(8, 2, 1, "E1"), M(2, 1, 1, "I1"),
});
ordered.Select(m => m.Code).ShouldBe(new[] { "E1", "E2", "E3", "W1", "I1" });
}
[Fact]
public void Null_or_empty_markers_order_to_an_empty_list() => ScriptProblems.Order(null).ShouldBeEmpty();
[Theory]
[InlineData(8, "Error")]
[InlineData(4, "Warning")]
[InlineData(2, "Info")]
[InlineData(1, "Hint")]
public void Severity_labels_follow_monaco_values(int severity, string label)
=> ScriptProblems.SeverityLabel(severity).ShouldBe(label);
[Fact]
public void Summary_counts_errors_and_warnings()
{
ScriptProblems.Summary(Array.Empty<DiagnosticMarker>()).ShouldBe("No problems");
ScriptProblems.Summary(new[] { M(8, 1, 1) }).ShouldBe("1 error");
ScriptProblems.Summary(new[] { M(8, 1, 1), M(8, 2, 1), M(4, 3, 1) }).ShouldBe("2 errors, 1 warning");
ScriptProblems.Summary(new[] { M(4, 1, 1), M(4, 2, 1), M(2, 3, 1) }).ShouldBe("2 warnings, 1 other");
}
[Fact]
public void The_inlay_hints_endpoint_is_gone()
{
// The always-empty inlay-hints endpoint (and the JS provider that POSTed the full source to it on every
// edit) were removed. Positive control: the other five routes are still mapped.
var (app, _) = AnalysisCancellationTests.BuildApp();
var routes = AnalysisCancellationTests.Endpoints(app).Select(e => e.RoutePattern.RawText!).ToList();
routes.ShouldNotContain(r => r.Contains("inlay", StringComparison.OrdinalIgnoreCase));
foreach (var r in new[] { "/diagnostics", "/completions", "/hover", "/signature-help", "/format" })
routes.ShouldContain(x => x.EndsWith(r));
}
}