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:
Joseph Doherty
2026-10-22 18:14:53 -04:00
85 changed files with 4618 additions and 812 deletions
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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();
}
}