Merge master 4fb04a9f (lane D-a) into remed/lane-D-b
ScriptEdit.razor conflict resolved keeping both D-a's on-demand Monaco sync + Problems list and D-b's delete confirm. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:P2 — an abandoned editor request (Monaco cancelled it, or the browser aborted the fetch) used to run
|
||||
/// its full compile to completion. The request's token now reaches the service, the Roslyn calls and the
|
||||
/// catalog, and the endpoints bind <c>HttpContext.RequestAborted</c>.
|
||||
/// </summary>
|
||||
public sealed class AnalysisCancellationTests
|
||||
{
|
||||
internal sealed class CapturingCatalog : IScriptTagCatalog
|
||||
{
|
||||
public readonly List<CancellationToken> Seen = new();
|
||||
|
||||
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
|
||||
{
|
||||
Seen.Add(ct);
|
||||
return Task.FromResult<IReadOnlyList<string>>(new[] { "Plant/Dev/Speed" });
|
||||
}
|
||||
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
{
|
||||
Seen.Add(ct);
|
||||
return Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Tag", "Float", "DRV-1"));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
{
|
||||
Seen.Add(ct);
|
||||
return Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
|
||||
}
|
||||
}
|
||||
|
||||
private static CancellationToken Cancelled() => new(canceled: true);
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnose_with_a_cancelled_token_throws_instead_of_returning_empty_markers()
|
||||
{
|
||||
var svc = new ScriptAnalysisService();
|
||||
// Positive control: the same request with a live token produces the error marker.
|
||||
(await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), CancellationToken.None))
|
||||
.Markers.ShouldContain(m => m.Severity == 8);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), Cancelled()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completions_hover_and_signature_help_honour_a_cancelled_token()
|
||||
{
|
||||
var svc = new ScriptAnalysisService(new CapturingCatalog());
|
||||
const string code = "return ctx.GetTag(\"\");";
|
||||
|
||||
// Positive controls.
|
||||
(await svc.CompleteAsync(new CompletionsRequest(code, 1, 20), CancellationToken.None)).Items.ShouldNotBeEmpty();
|
||||
(await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), CancellationToken.None))
|
||||
.Markdown.ShouldNotBeNull();
|
||||
svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), CancellationToken.None).Label.ShouldNotBeNull();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(svc.CompleteAsync(new CompletionsRequest(code, 1, 20), Cancelled()));
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), Cancelled()));
|
||||
Should.Throw<OperationCanceledException>(() => svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), Cancelled()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_request_token_reaches_the_catalog()
|
||||
{
|
||||
var catalog = new CapturingCatalog();
|
||||
var svc = new ScriptAnalysisService(catalog);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await svc.CompleteAsync(new CompletionsRequest("return ctx.GetTag(\"\");", 1, 20), cts.Token);
|
||||
await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), cts.Token);
|
||||
await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.GetTag(\"{{equip}}/Nope\").Value;", "EQ-1"), cts.Token);
|
||||
|
||||
catalog.Seen.Count.ShouldBe(3);
|
||||
catalog.Seen.ShouldAllBe(t => t == cts.Token);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- endpoints
|
||||
|
||||
// A bare DefaultHttpContext reports "no body"; Kestrel sets this feature for a real POST.
|
||||
private sealed class HasBody : Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature
|
||||
{
|
||||
public bool CanHaveBody => true;
|
||||
}
|
||||
|
||||
internal static (WebApplication App, CapturingCatalog Catalog) BuildApp()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
var catalog = new CapturingCatalog();
|
||||
builder.Services.AddSingleton<IScriptTagCatalog>(catalog);
|
||||
builder.Services.AddScoped(sp => new ScriptAnalysisService(sp.GetRequiredService<IScriptTagCatalog>()));
|
||||
builder.Services.AddAuthorization();
|
||||
var app = builder.Build();
|
||||
app.MapScriptAnalysisEndpoints();
|
||||
return (app, catalog);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<RouteEndpoint> Endpoints(WebApplication app)
|
||||
=> ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType<RouteEndpoint>().ToList();
|
||||
|
||||
[Fact]
|
||||
public async Task The_endpoint_binds_RequestAborted_through_to_the_catalog()
|
||||
{
|
||||
var (app, catalog) = BuildApp();
|
||||
var endpoint = Endpoints(app).Single(e => e.RoutePattern.RawText!.EndsWith("/completions"));
|
||||
|
||||
using var scope = app.Services.CreateScope();
|
||||
using var aborted = new CancellationTokenSource();
|
||||
var ctx = new DefaultHttpContext { RequestServices = scope.ServiceProvider, RequestAborted = aborted.Token };
|
||||
ctx.Request.Method = "POST";
|
||||
ctx.Request.ContentType = "application/json";
|
||||
var body = Encoding.UTF8.GetBytes("""{"codeText":"return ctx.GetTag(\"\");","line":1,"column":20}""");
|
||||
ctx.Request.Body = new MemoryStream(body);
|
||||
ctx.Request.ContentLength = body.Length;
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
ctx.Features.Set<Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature>(new HasBody());
|
||||
|
||||
await endpoint.RequestDelegate!(ctx);
|
||||
|
||||
ctx.Response.StatusCode.ShouldBe(200);
|
||||
catalog.Seen.ShouldHaveSingleItem().ShouldBe(aborted.Token);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:P2 — the editor used to build a brand-new <c>CSharpCompilation</c> (and re-bind the whole sandbox
|
||||
/// reference set) on every completion / hover / signature-help / diagnostics request. Each analysis now
|
||||
/// derives from ONE process-wide base compilation via <c>ReplaceSyntaxTree</c>, so the referenced-assembly
|
||||
/// binding is done once and shared.
|
||||
/// </summary>
|
||||
public sealed class AnalysisCompilationReuseTests
|
||||
{
|
||||
[Fact]
|
||||
public void Two_services_share_the_bound_reference_set()
|
||||
{
|
||||
// Two separate scopes (two HTTP requests) analysing different sources.
|
||||
var c1 = new ScriptAnalysisService().CompilationFor("return 1;");
|
||||
var c2 = new ScriptAnalysisService().CompilationFor("return 2;");
|
||||
|
||||
// Positive control: the two compilations really carry different user sources.
|
||||
c1.SyntaxTrees.Single().ToString().ShouldContain("return 1;");
|
||||
c2.SyntaxTrees.Single().ToString().ShouldContain("return 2;");
|
||||
c1.ShouldNotBeSameAs(c2);
|
||||
|
||||
// Derived from one bound base: the corlib symbol is the SAME instance, not a re-bound copy.
|
||||
c1.GetSpecialType(SpecialType.System_Object)
|
||||
.ShouldBeSameAs(c2.GetSpecialType(SpecialType.System_Object));
|
||||
c1.GetSpecialType(SpecialType.System_Object)
|
||||
.ShouldBeSameAs(ScriptAnalysisService.BaseCompilation.GetSpecialType(SpecialType.System_Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repeated_analysis_in_one_scope_replaces_the_tree_rather_than_adding_one()
|
||||
{
|
||||
var svc = new ScriptAnalysisService();
|
||||
svc.CompilationFor("return 1;");
|
||||
var second = svc.CompilationFor("return 2;");
|
||||
|
||||
second.SyntaxTrees.Count().ShouldBe(1);
|
||||
second.SyntaxTrees.Single().ToString().ShouldContain("return 2;");
|
||||
second.SyntaxTrees.Single().ToString().ShouldNotContain("return 1;");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostics_are_unchanged_by_reuse()
|
||||
{
|
||||
// A clean script stays clean and an error stays an error across interleaved analyses on one service
|
||||
// — a stale tree leaking from the previous call would surface here.
|
||||
var svc = new ScriptAnalysisService();
|
||||
svc.Diagnose(new DiagnoseRequest("return ctx.Nope();")).Markers.ShouldContain(m => m.Severity == 8);
|
||||
svc.Diagnose(new DiagnoseRequest("""return (double)ctx.GetTag("A").Value * 2.0;""")).Markers.ShouldBeEmpty();
|
||||
svc.Diagnose(new DiagnoseRequest("var a = 1;\nreturn ctx.Nope();")).Markers
|
||||
.ShouldContain(m => m.Severity == 8 && m.StartLineNumber == 2);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Review D-a I-1 — the diagnostics markers cross the Blazor circuit (JS → .NET, for the Problems list) as ONE
|
||||
/// hub message. A non-C# paste yields several Roslyn errors per line; uncapped, a few thousand markers exceed
|
||||
/// the 1 MB hub limit and kill the circuit with every unsaved edit. Both the endpoint response and the circuit
|
||||
/// report are now bounded; these tests pin the bounds on the C# side of the contract.
|
||||
/// </summary>
|
||||
public sealed class MarkerPayloadBoundTests
|
||||
{
|
||||
// ~150 KB of log-ish text: every line is several C# errors.
|
||||
private static string NonCSharpPaste()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; sb.Length < 150 * 1024; i++)
|
||||
sb.Append($"2026-10-22 12:00:{i % 60:00} INFO [worker-{i}] value={i}, state=ok; next -> {i + 1} @@ ##\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_non_csharp_paste_returns_a_bounded_marker_set_that_still_holds_the_errors()
|
||||
{
|
||||
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest(NonCSharpPaste())).Markers;
|
||||
|
||||
markers.Count.ShouldBe(ScriptEditorLimits.MaxDiagnosticMarkers); // positive control: the cap was hit
|
||||
markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars);
|
||||
markers[0].Severity.ShouldBe(8); // errors first — publish rejects, so must the editor
|
||||
markers.Select(m => (m.Severity, m.StartLineNumber, m.StartColumn))
|
||||
.ShouldBe(ScriptProblems.Order(markers).Select(m => (m.Severity, m.StartLineNumber, m.StartColumn)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_long_quoted_token_has_its_message_truncated()
|
||||
{
|
||||
var giant = new string('x', 20_000);
|
||||
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest($"return {giant};")).Markers;
|
||||
|
||||
markers.ShouldNotBeEmpty();
|
||||
markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars);
|
||||
markers.ShouldContain(m => m.Message.EndsWith("…"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_small_script_is_not_truncated()
|
||||
{
|
||||
// Positive control for the cap: ordinary diagnostics pass through untouched.
|
||||
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest("return ctx.Nope();")).Markers;
|
||||
markers.ShouldContain(m => m.Severity == 8 && !m.Message.EndsWith("…"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_worst_case_circuit_report_fits_inside_the_sync_budget()
|
||||
{
|
||||
// The JS sends at most MaxReportedMarkers markers, each message cut to MaxReportedMessageChars. Worst case:
|
||||
// control characters (\u0001 → 6 JSON bytes, 7 when escaped again) and the largest line/column numbers.
|
||||
var message = new string('\u0001', ScriptEditorLimits.MaxReportedMessageChars);
|
||||
var marker = new DiagnosticMarker(8, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, message, "OTSCRIPT_SETVTAG_INERT");
|
||||
var report = Enumerable.Repeat(marker, ScriptEditorLimits.MaxReportedMarkers).ToArray();
|
||||
|
||||
var once = JsonSerializer.Serialize(new object[] { report, int.MaxValue });
|
||||
var twice = Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(once));
|
||||
|
||||
twice.ShouldBeLessThan(ScriptEditorLimits.MaxEditorSyncBytes);
|
||||
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_truncated_report_says_how_many_were_left_out()
|
||||
{
|
||||
var shown = Enumerable.Range(1, 3).Select(i => new DiagnosticMarker(8, i, 1, i, 2, "m", "CS1002")).ToArray();
|
||||
|
||||
var report = new ScriptProblemReport(shown, 4321);
|
||||
report.Omitted.ShouldBe(4318);
|
||||
ScriptProblems.Summary(report).ShouldBe("3 errors (showing 3 of 4321)");
|
||||
|
||||
// Positive control: an untruncated report carries no suffix.
|
||||
ScriptProblems.Summary(new ScriptProblemReport(shown, 3)).ShouldBe("3 errors");
|
||||
ScriptProblemReport.Empty.Omitted.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_report_from_js_is_clamped_even_if_the_js_cap_regressed()
|
||||
{
|
||||
var flood = Enumerable.Range(1, 5000).Select(i => new DiagnosticMarker(4, i, 1, i, 2, new string('m', 2000), "W")).ToArray();
|
||||
|
||||
var report = ScriptProblemReport.FromEditor(flood, total: 5000);
|
||||
|
||||
report.Markers.Count.ShouldBe(ScriptEditorLimits.MaxReportedMarkers);
|
||||
report.Total.ShouldBe(5000);
|
||||
report.Markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxReportedMessageChars);
|
||||
ScriptProblemReport.FromEditor(null, 0).ShouldBe(ScriptProblemReport.Empty);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
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) {");
|
||||
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("reportMarkers = MarkersChanged.HasDelegate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_markers_report_is_capped_before_it_crosses_the_circuit()
|
||||
{
|
||||
// Review I-1: the only OnMarkersChanged invoke sends the capped report, never the raw marker array.
|
||||
var js = Js();
|
||||
Regex.Matches(js, "\"OnMarkersChanged\"").Count.ShouldBe(1);
|
||||
js.ShouldContain("dotNetRef.invokeMethodAsync(\"OnMarkersChanged\", report.markers, report.total)");
|
||||
js.ShouldContain("const report = markerReport(e, markers);");
|
||||
js.ShouldContain(".slice(0, max)");
|
||||
js.ShouldContain("m.message.substring(0, maxChars - 1)");
|
||||
// The caps come from the C# constants, so the JS and ScriptProblemReport.FromEditor agree.
|
||||
var razor = Read("Components", "Shared", "MonacoEditor.razor");
|
||||
razor.ShouldContain("maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers");
|
||||
razor.ShouldContain("maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars");
|
||||
razor.ShouldContain("ScriptProblemReport.FromEditor(markers, total)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_oversize_text_is_announced_at_blur_not_only_at_save()
|
||||
{
|
||||
Js().ShouldContain("invokeMethodAsync(\"OnSyncRefused\", serializedBytes(v))");
|
||||
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("public void OnSyncRefused(int bytes)");
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:P3 — the catalog used to build a HashSet of every fleet path and sort it on every completion keystroke,
|
||||
/// and hover did a linear scan. The snapshot now carries a sorted, de-duplicated <c>string[]</c> (prefix-searched
|
||||
/// with a binary search) and an Ordinal path→info dictionary, built once per snapshot.
|
||||
/// </summary>
|
||||
public sealed class ScriptTagCatalogIndexTests
|
||||
{
|
||||
private const int Max = 200;
|
||||
|
||||
// The previous per-keystroke implementation, kept here as the oracle the index must match exactly.
|
||||
private static IReadOnlyList<string> Naive(IReadOnlyList<ScriptTagInfo> entries, string? filter)
|
||||
{
|
||||
IEnumerable<string> q = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
q = q.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||
return q.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).Take(Max).ToList();
|
||||
}
|
||||
|
||||
private static ScriptTagInfo Tag(string path) => new(path, "Tag", "Float", "DRV-1");
|
||||
|
||||
[Fact]
|
||||
public void Prefix_search_is_case_insensitive_sorted_and_bounded()
|
||||
{
|
||||
var index = ScriptTagCatalogIndex.Build(new[]
|
||||
{
|
||||
Tag("Plant/b/Speed"), Tag("plant/A/Temp"), Tag("Other/X"), Tag("Plant/a/Flow"),
|
||||
});
|
||||
|
||||
index.FindByPrefix("PLANT/", Max).ShouldBe(new[] { "Plant/a/Flow", "plant/A/Temp", "Plant/b/Speed" });
|
||||
index.FindByPrefix("other", Max).ShouldBe(new[] { "Other/X" });
|
||||
index.FindByPrefix("zzz", Max).ShouldBeEmpty();
|
||||
index.FindByPrefix(null, 2).Count.ShouldBe(2);
|
||||
index.FindByPrefix(" ", Max).Count.ShouldBe(4); // whitespace filter = unfiltered, as before
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Case_only_duplicates_collapse_to_the_first_seen_casing()
|
||||
{
|
||||
// HashSet(OrdinalIgnoreCase) kept the FIRST-added casing; the index must too.
|
||||
var index = ScriptTagCatalogIndex.Build(new[] { Tag("A/speed"), Tag("A/Speed"), Tag("A/SPEED") });
|
||||
index.SortedPaths.ShouldBe(new[] { "A/speed" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exact_lookup_is_ordinal_and_keeps_the_first_entry()
|
||||
{
|
||||
var first = new ScriptTagInfo("A/B", "Tag", "Float", "DRV-1");
|
||||
var index = ScriptTagCatalogIndex.Build(new[] { first, new ScriptTagInfo("A/B", "Virtual tag", "Int32", null) });
|
||||
|
||||
index.TryGet("A/B").ShouldBeSameAs(first);
|
||||
index.TryGet("a/b").ShouldBeNull(); // case-mismatched path does not resolve at runtime
|
||||
index.TryGet("A/C").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_the_previous_implementation_on_a_generated_fleet()
|
||||
{
|
||||
// Deterministic seed — a mixed-case fleet with case-only duplicates and shared prefixes.
|
||||
var rng = new Random(745);
|
||||
string Seg() => new string(Enumerable.Range(0, rng.Next(1, 4)).Select(_ => "abAB_/1"[rng.Next(7)]).ToArray());
|
||||
var entries = Enumerable.Range(0, 3000).Select(_ => Tag(Seg() + "/" + Seg() + "/" + Seg())).ToList();
|
||||
var index = ScriptTagCatalogIndex.Build(entries);
|
||||
|
||||
var filters = new List<string?> { null, "", "a", "A", "ab", "aB/", "b/", "_", "1", "/", "zz" };
|
||||
filters.AddRange(entries.Take(50).Select(e => e.Path[..Math.Min(e.Path.Length, rng.Next(1, 6))]));
|
||||
|
||||
foreach (var f in filters)
|
||||
index.FindByPrefix(f, Max).ShouldBe(Naive(entries, f), $"filter '{f}'");
|
||||
// Positive control: the generated fleet really exercises truncation and filtering.
|
||||
Naive(entries, null).Count.ShouldBe(Max);
|
||||
Naive(entries, "ab").Count.ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:S1 — <c>ctx.SetVirtualTag(...)</c> compiles and publishes but is dropped at runtime (single-tag mode).
|
||||
/// Only a hover used to say so; <c>Diagnose</c> now emits an <c>OTSCRIPT_SETVTAG_INERT</c> WARNING marker so the
|
||||
/// Problems list shows it. A warning, never an error: publish accepts the script, so the editor must too.
|
||||
/// </summary>
|
||||
public sealed class SetVirtualTagInertDiagnosticTests
|
||||
{
|
||||
private const string InertCode = "OTSCRIPT_SETVTAG_INERT";
|
||||
private static readonly ScriptAnalysisService Svc = new();
|
||||
private static IReadOnlyList<DiagnosticMarker> Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers;
|
||||
|
||||
[Fact]
|
||||
public void SetVirtualTag_call_gets_a_warning_marker_on_its_line()
|
||||
{
|
||||
var markers = Diag("var a = 1;\nvar b = 2;\nctx.SetVirtualTag(\"Other\", a + b);\nreturn 0;");
|
||||
|
||||
var m = markers.ShouldHaveSingleItem();
|
||||
m.Code.ShouldBe(InertCode);
|
||||
m.Severity.ShouldBe(4); // Monaco warning
|
||||
m.StartLineNumber.ShouldBe(3);
|
||||
m.StartColumn.ShouldBe(1);
|
||||
m.Message.ShouldContain("SetVirtualTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_SetVirtualTag_call_is_marked()
|
||||
=> Diag("ctx.SetVirtualTag(\"A\", 1);\nctx.SetVirtualTag(\"B\", 2);\nreturn 0;")
|
||||
.Count(m => m.Code == InertCode).ShouldBe(2);
|
||||
|
||||
[Theory]
|
||||
[InlineData("return ctx.GetTag(\"A\").Value;")] // a different ctx member
|
||||
[InlineData("return 0; // ctx.SetVirtualTag(\"A\", 1)")] // a comment
|
||||
[InlineData("var s = \"ctx.SetVirtualTag(\\\"A\\\", 1)\"; return s;")] // a string literal
|
||||
public void Non_calls_are_not_marked(string code)
|
||||
{
|
||||
var markers = Diag(code);
|
||||
markers.ShouldNotContain(m => m.Code == InertCode);
|
||||
markers.ShouldNotContain(m => m.Severity == 8); // the probe scripts themselves are valid
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_warning_does_not_block_and_publish_accepts_the_script()
|
||||
{
|
||||
// Editor accepts ⇔ publish accepts: no error-severity marker, and the real publish compiler accepts it.
|
||||
const string source = "ctx.SetVirtualTag(\"Other\", 1);\nreturn 0;";
|
||||
Diag(source).ShouldNotContain(m => m.Severity == 8);
|
||||
using var evaluator = ScriptEvaluator<VirtualTagContext, object>.Compile(source);
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
+60
@@ -414,4 +414,64 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-bits")).ShouldBeFalse();
|
||||
}
|
||||
/// <summary>
|
||||
/// #741 (Modbus lane U3, follow-up F1): the Modbus driver encodes String[] and BitInRegister[] holding-register
|
||||
/// arrays, so an authored-<c>ReadWrite</c> array of each materialises WRITABLE — raw node and UNS mirror alike. A
|
||||
/// Coils array authored with a non-Bool type (the resolver coerces a bit region to Bool) is writable too. Shapes
|
||||
/// the driver refuses stay read-only on the SAME driver: a BitInRegister array over the FC16 cap, a String array
|
||||
/// with no <c>stringLength</c>, and a string-only (<c>addressString</c>) array whose family the composer cannot
|
||||
/// know — the negative controls.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Modbus_string_and_bit_in_register_arrays_materialise_writable_and_refused_shapes_stay_read_only()
|
||||
{
|
||||
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
||||
var mb = new DriverInstance { DriverInstanceId = "drv-mb", ClusterId = "c1", RawFolderId = "f", Name = "Mb", DriverType = "Modbus", DriverConfig = "{}" };
|
||||
var dev = new Device { DeviceId = "d1", DriverInstanceId = "drv-mb", Name = "plc", DeviceConfig = "{}" };
|
||||
static Tag Arr(string id, string dataType, string tagConfig) => new()
|
||||
{
|
||||
TagId = id, DeviceId = "d1", Name = id, DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = tagConfig,
|
||||
};
|
||||
var tags = new[]
|
||||
{
|
||||
Arr("names", "String", "{\"region\":\"HoldingRegisters\",\"address\":100,\"dataType\":\"String\",\"stringLength\":8,\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("flags", "Boolean", "{\"region\":\"HoldingRegisters\",\"address\":200,\"dataType\":\"BitInRegister\",\"bitIndex\":3,\"isArray\":true,\"arrayLength\":8}"),
|
||||
Arr("legacyCoils", "Int16", "{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":8}"),
|
||||
Arr("overCap", "Boolean", "{\"region\":\"HoldingRegisters\",\"address\":300,\"dataType\":\"BitInRegister\",\"isArray\":true,\"arrayLength\":124}"),
|
||||
Arr("noLength", "String", "{\"region\":\"HoldingRegisters\",\"address\":400,\"dataType\":\"String\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("stringOnly", "Int16", "{\"addressString\":\"40501:S::4\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
||||
var refs = new[]
|
||||
{
|
||||
new UnsTagReference { UnsTagReferenceId = "r-names", EquipmentId = "e", TagId = "names" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-flags", EquipmentId = "e", TagId = "flags" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-over", EquipmentId = "e", TagId = "overCap" },
|
||||
};
|
||||
|
||||
var c = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip }, new[] { mb }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { dev });
|
||||
|
||||
foreach (var writable in new[] { "names", "flags", "legacyCoils" })
|
||||
{
|
||||
var plan = c.RawTags.Single(t => t.Name == writable);
|
||||
plan.ArrayWritable.ShouldBeTrue(writable);
|
||||
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeTrue(writable);
|
||||
}
|
||||
foreach (var refused in new[] { "overCap", "noLength", "stringOnly" })
|
||||
{
|
||||
var plan = c.RawTags.Single(t => t.Name == refused);
|
||||
plan.IsArray.ShouldBeTrue(refused); // still an array node — just not a writable one
|
||||
plan.ArrayWritable.ShouldBeFalse(refused);
|
||||
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeFalse(refused);
|
||||
}
|
||||
|
||||
foreach (var id in new[] { "r-names", "r-flags" })
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-over")).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user