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:
+80
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user