diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor index e0a1d8af..20190df1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor @@ -11,6 +11,7 @@ @using System.Text @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis @inject IDbContextFactory DbFactory @inject NavigationManager Nav @@ -30,7 +31,9 @@ else if (!IsNew && _existing is null) } else { - + @* OnSubmit (not OnValidSubmit): the editor text is pulled from Monaco FIRST, then validated — the bound + SourceCode is only refreshed at blur, so validating before the pull could judge stale text. *@ +
@@ -57,7 +60,9 @@ else
Source
- + +
SHA-256 hash is computed automatically on save.
@@ -84,6 +89,13 @@ else private bool _loaded; private bool _busy; private string? _error; + private MonacoEditor? _editor; + private ScriptProblemReport _markers = ScriptProblemReport.Empty; + + private void OnMarkersChanged(ScriptProblemReport report) => _markers = report; + + private Task RevealProblemAsync(DiagnosticMarker m) + => _editor is null ? Task.CompletedTask : _editor.RevealLineAsync(m.StartLineNumber, m.StartColumn); protected override async Task OnInitializedAsync() { @@ -105,11 +117,21 @@ else _loaded = true; } - private async Task SubmitAsync() + private async Task SubmitAsync(EditContext editContext) { _busy = true; _error = null; try { + // The editor reports its text only at blur, so read the authoritative current value now. A read + // failure (e.g. the script is too large to send) aborts the save rather than persisting stale source. + if (_editor is not null) + { + var read = await _editor.GetValueAsync(); + if (!read.Ok) { _error = $"Not saved: {read.Error}"; return; } + _form.SourceCode = read.Value!; + } + if (!editContext.Validate()) return; + var sourceHash = HashSource(_form.SourceCode); await using var db = await DbFactory.CreateDbContextAsync(); if (IsNew) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor index d4c73da1..9c763b57 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor @@ -1,5 +1,6 @@ @namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared @using Microsoft.Extensions.Logging +@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis @implements IAsyncDisposable @inject IJSRuntime JS @inject Microsoft.Extensions.Logging.ILogger Logger @@ -20,9 +21,23 @@
+@if (_tooLargeBytes > 0) +{ + @* Non-blocking: the author keeps editing, but learns now (not at Save) that this text cannot be sent. *@ +
+ This script is too large to send to the server (@_tooLargeBytes bytes; the limit is + @ScriptEditorLimits.MaxEditorSyncBytes bytes). It cannot be saved until it is shortened. +
+} @code { + /// + /// The editor text. Two-way bound, but the editor reports edits back only when it loses focus — never per + /// keystroke (each report is one circuit message carrying the whole script). A page that persists the text + /// must read the authoritative current value with at save time. + /// [Parameter] public string Value { get; set; } = ""; + /// Raised with the editor text when the editor loses focus after an edit (and from ). [Parameter] public EventCallback ValueChanged { get; set; } [Parameter] public string Language { get; set; } = "csharp"; [Parameter] public string Height { get; set; } = "320px"; @@ -37,17 +52,20 @@ [Parameter] public string? EquipmentId { get; set; } /// - /// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic - /// debounce). The marker DTO is not modelled yet — typed as object[] until a - /// later task wires the Roslyn-backed diagnostics. + /// Fires whenever Monaco's diagnostic marker set updates (after the 500 ms debounce, and only for the response + /// to the CURRENT text — superseded responses are dropped in the JS). Feeds ScriptProblemsList; the + /// markers are sent from JS only when this callback has a subscriber, and at most + /// of them (plus the total) so the report always fits in one + /// circuit message. /// - [Parameter] public EventCallback MarkersChanged { get; set; } + [Parameter] public EventCallback MarkersChanged { get; set; } private ElementReference _hostRef; private DotNetObjectReference? _dotNetRef; private readonly string _id = Guid.NewGuid().ToString("N"); private string _lastSentValue = ""; private bool _initialized; + private int _tooLargeBytes; private bool _wrap; private bool _minimap; private bool _dark; @@ -69,7 +87,11 @@ value = Value ?? "", language = Language, readOnly = ReadOnly, - equipmentId = EquipmentId + equipmentId = EquipmentId, + maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes, + reportMarkers = MarkersChanged.HasDelegate, + maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers, + maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars }, _dotNetRef); _initialized = true; @@ -126,9 +148,57 @@ return ValueChanged.InvokeAsync(_lastSentValue); } + /// + /// Reads the editor's current text on demand (call it at Save), raises when it + /// differs from the last value reported, and returns it. Before the editor is live (prerender, failed init) + /// the bound is authoritative and is returned as-is. + /// + /// The current text, or an error (e.g. the script is too large to send) — never a stale value. + public async Task GetValueAsync() + { + if (!_initialized) return new EditorReadResult(Value ?? "", null); + string? current; + try + { + current = await JS.InvokeAsync("MonacoBlazor.getValue", _id); + } + catch (JSDisconnectedException) + { + return new EditorReadResult(null, "The connection to the editor was lost; reload the page and try again."); + } + catch (JSException ex) + { + Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to read the editor text.", _id); + // JSException.Message carries the JS stack after the first line; the author needs only the reason. + return new EditorReadResult(null, ex.Message.Split('\n', 2)[0].Trim()); + } + if (current is null) return new EditorReadResult(Value ?? "", null); // editor already disposed + _tooLargeBytes = 0; // it was read, so it fits + await OnValueChanged(current); + return new EditorReadResult(current, null); + } + + /// The outcome of : the text, or why it could not be read. + /// The editor text; null when is set. + /// Why the text could not be read; null on success. + public sealed record EditorReadResult(string? Value, string? Error) + { + /// True when holds the editor text. + public bool Ok => Error is null; + } + [JSInvokable] - public Task OnMarkersChanged(object[] markers) => - MarkersChanged.InvokeAsync(markers ?? Array.Empty()); + public Task OnMarkersChanged(DiagnosticMarker[]? markers, int total) => + MarkersChanged.InvokeAsync(ScriptProblemReport.FromEditor(markers, total)); + + /// The JS reports, at blur, that the text has grown past (bytes > 0) or back under (0) the sync limit. + [JSInvokable] + public void OnSyncRefused(int bytes) + { + if (_tooLargeBytes == bytes) return; + _tooLargeBytes = bytes; + StateHasChanged(); + } /// Programmatic scroll-to-line (called by the problems panel). public async Task RevealLineAsync(int line, int column = 1) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ScriptProblemsList.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ScriptProblemsList.razor new file mode 100644 index 00000000..7ebd9e4a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ScriptProblemsList.razor @@ -0,0 +1,60 @@ +@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared +@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis +@* The script editor's Problems list. Fed by MonacoEditor.MarkersChanged; clicking a row calls OnSelect, which the + host wires to MonacoEditor.RevealLineAsync so the caret jumps to the problem. Ordering and wording live in + ScriptProblems (unit-tested). *@ + +@{ + var report = Report ?? ScriptProblemReport.Empty; + var ordered = ScriptProblems.Order(report.Markers); +} +
+
+ Problems + @ScriptProblems.Summary(report with { Markers = ordered }) +
+ @if (ordered.Count > 0) + { +
    + @foreach (var m in ordered.Take(MaxRows)) + { +
  • + @ScriptProblems.SeverityLabel(m.Severity) + @m.StartLineNumber:@m.StartColumn + @m.Message + @m.Code +
  • + } +
+ @if (report.Total > Math.Min(ordered.Count, MaxRows)) + { + @* The editor reports at most ScriptEditorLimits.MaxReportedMarkers problems (one circuit message); the + rest are still squiggled in the editor. *@ +
+@(report.Total - Math.Min(ordered.Count, MaxRows)) more — fix the problems above, or scroll the editor to see them all.
+ } + } +
+ +@code { + private const int MaxRows = ScriptEditorLimits.MaxReportedMarkers; + + /// The report the editor last sent (null before the first diagnostics pass). + [Parameter] public ScriptProblemReport? Report { get; set; } + + /// Raised when the author picks a problem; wire it to MonacoEditor.RevealLineAsync. + [Parameter] public EventCallback OnSelect { get; set; } + + private Task OnKeyAsync(KeyboardEventArgs e, DiagnosticMarker m) + => e.Key is "Enter" or " " ? OnSelect.InvokeAsync(m) : Task.CompletedTask; + + private static string BadgeClass(int severity) => severity switch + { + ScriptProblems.Error => "text-bg-danger", + ScriptProblems.Warning => "text-bg-warning", + _ => "text-bg-secondary", + }; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor index b28e4642..789d6ccd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor @@ -7,6 +7,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis @inject IUnsTreeService Svc @if (Visible) @@ -99,7 +100,9 @@ Editing shared script "@_scriptName" — used by @_scriptUsageCount virtual tag(s). Changes affect all of them. - + + @if (!string.IsNullOrWhiteSpace(_scriptError)) {
@_scriptError
@@ -217,6 +220,24 @@ private bool _scriptSaving; private bool _scriptSaved; private string? _scriptError; + private MonacoEditor? _scriptEditor; + private ScriptProblemReport _scriptMarkers = ScriptProblemReport.Empty; + + private void OnScriptMarkersChanged(ScriptProblemReport report) => _scriptMarkers = report; + + private Task RevealScriptProblemAsync(DiagnosticMarker m) + => _scriptEditor is null ? Task.CompletedTask : _scriptEditor.RevealLineAsync(m.StartLineNumber, m.StartColumn); + + // The inline editor reports its text only at blur; pull the authoritative current value before acting on it. + // Returns false (with _scriptError set) when the text could not be read, so nothing stale is saved. + private async Task PullScriptSourceAsync() + { + if (_scriptEditor is null) return true; + var read = await _scriptEditor.GetValueAsync(); + if (!read.Ok) { _scriptError = $"Not saved: {read.Error}"; return false; } + _scriptSource = read.Value!; + return true; + } // "New script" action state (shown only when no script is bound yet). private bool _scriptCreating; @@ -227,6 +248,9 @@ if (!Visible) { _loadedKey = null; // closed → next open reloads fresh + // Closing removes the editor component: drop its reference and its problems. + _scriptEditor = null; + _scriptMarkers = ScriptProblemReport.Empty; return; } @@ -278,6 +302,9 @@ { _scriptSaved = false; _scriptError = null; + // A different script (or a reload) — drop the previous script's problems. The editor instance itself stays: + // while the panel is open the same MonacoEditor component receives the new source via its Value. + _scriptMarkers = ScriptProblemReport.Empty; if (string.IsNullOrEmpty(_form.ScriptId)) { @@ -358,8 +385,16 @@ /// Toggles the inline script-source panel. Clears the stale "Script saved." banner when /// (re)expanding so a collapse/expand cycle doesn't re-show a confirmation from an earlier save. /// - private void ToggleScriptPanel() + private async Task ToggleScriptPanel() { + if (_scriptExpanded) + { + // Collapsing disposes the editor: keep its unsaved text in _scriptSource so re-opening shows it. + // A failed read keeps the panel open (with the error) rather than discarding the edits. + if (!await PullScriptSourceAsync()) return; + _scriptEditor = null; + _scriptMarkers = ScriptProblemReport.Empty; + } _scriptExpanded = !_scriptExpanded; if (_scriptExpanded) { @@ -384,6 +419,7 @@ _scriptError = null; try { + if (!await PullScriptSourceAsync()) return; var result = await Svc.UpdateScriptSourceAsync(_form.ScriptId, _scriptSource, _scriptRowVersion); if (result.Ok) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index cfb8063e..1240a033 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -53,7 +53,7 @@ public static class EndpointRouteBuilderExtensions /// The same service collection, for chaining. public static IServiceCollection AddAdminUI(this IServiceCollection services) { - services.AddRazorComponents().AddInteractiveServerComponents(); + services.AddRazorComponents().AddInteractiveServerComponents().AddHubOptions(o => o.MaximumReceiveMessageSize = ScriptAnalysis.ScriptEditorLimits.HubMaximumReceiveMessageSize); services.AddOtOpcUaDriverStatusServices(); // Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs index f4b652e2..18370bc3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs @@ -105,31 +105,20 @@ public sealed class ScriptTagCatalog( /// public async Task> GetPathsAsync(string? filter, CancellationToken ct) { - var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct); - - var paths = new HashSet(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase); - - IEnumerable query = paths; - if (!string.IsNullOrWhiteSpace(filter)) - { - query = query.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase)); - } - - return query - .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) - .Take(MaxResults) - .ToList(); + // The sorted, de-duplicated path array is built once per snapshot; a keystroke is a binary search. + var index = await cache.GetOrBuildIndexAsync(BuildEntriesAsync, ct); + return index.FindByPrefix(filter, MaxResults); } /// public async Task GetTagInfoAsync(string path, CancellationToken ct) { - var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct); + var index = await cache.GetOrBuildIndexAsync(BuildEntriesAsync, ct); // Exact, case-SENSITIVE (Ordinal) match: the live runtime resolves a ctx.GetTag("X") literal // via DependencyMuxActor keyed with StringComparer.Ordinal, so a case-mismatched path would // not resolve at runtime — hover must report it as "not a known configured tag path". - return entries.FirstOrDefault(e => string.Equals(e.Path, path, StringComparison.Ordinal)); + return index.TryGet(path); } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs index 2c71031c..1227957a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -22,7 +22,3 @@ public record SignatureHelpParameter(string Label, string? Documentation); public record FormatRequest(string Code); public record FormatResponse(string Code); - -public record InlayHintsRequest(string Code); -public record InlayHintsResponse(IReadOnlyList Hints); -public record InlayHint(int Line, int Column, string Label); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index 6783f56f..50739298 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -8,20 +8,37 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; public static class ScriptAnalysisEndpoints { + /// Status returned when the editor abandoned the request (Monaco cancelled it or the fetch was + /// aborted). Nobody reads the body; it only keeps a routine cancellation out of the error logs. + private const int ClientClosedRequest = 499; + /// Maps the /api/script-analysis endpoint group (diagnostics, completions, hover, signature help, format), gated by the ConfigEditor policy (Administrator or Designer). /// The route builder to map the endpoints onto. /// The same route builder, for chaining. + /// Every analysis handler binds the request's (HttpContext.RequestAborted) + /// and passes it to the analysis, so a request the editor abandoned stops compiling instead of running to completion. public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints) { // ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate. var group = endpoints.MapGroup("/api/script-analysis") .RequireAuthorization(AdminUiPolicies.ConfigEditor); - group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r))); - group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r))); - group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r))); - group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r))); + group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.DiagnoseAsync(r, ct), ct)); + group.MapPost("/completions", (CompletionsRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.CompleteAsync(r, ct), ct)); + group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.Hover(r, ct), ct)); + group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => Task.FromResult(s.SignatureHelp(r, ct)), ct)); group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r))); - group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r))); return endpoints; } + + private static async Task Run(Func> analyse, CancellationToken ct) + { + try + { + return Results.Ok(await analyse()); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return Results.StatusCode(ClientClosedRequest); + } + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs index 23d610b7..7182324c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -18,10 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; /// context. /// /// -/// All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format -/// are wired and backed by the real Roslyn document/analysis pipeline. Only InlayHints is -/// intentionally a no-op (empty list) — the endpoint exists for future use but the feature is -/// not implemented. +/// All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format +/// are wired and backed by the real Roslyn document/analysis pipeline. +/// Cost model. Every analysis derives from one process-wide +/// via , so the sandbox reference set is bound once rather +/// than per keystroke, and every entry point takes the request's +/// (HttpContext.RequestAborted) so an abandoned request stops instead of running to completion. /// public sealed class ScriptAnalysisService { @@ -56,16 +58,39 @@ public sealed class ScriptAnalysisService + " var ctx = globals.ctx;\n#line 1\n"; } - // userSource is appended after Preamble, then the method/class are closed. - private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(string userSource) + private static SyntaxTree WrapAndParse(string userSource, CancellationToken ct) + => CSharpSyntaxTree.ParseText(Preamble + (userSource ?? "") + "\n }\n}\n", cancellationToken: ct); + + /// + /// The process-wide compilation every analysis derives from. Its sandbox reference set is bound ONCE + /// (forced here by resolving System.Object); keeps + /// that binding, so a request only re-binds the user's tree instead of the whole reference set. It is + /// immutable, so sharing it across circuits and requests is thread-safe. + /// + internal static readonly CSharpCompilation BaseCompilation = CreateBaseCompilation(); + + private static CSharpCompilation CreateBaseCompilation() { - var prefix = Preamble; - var full = prefix + (userSource ?? "") + "\n }\n}\n"; - var tree = CSharpSyntaxTree.ParseText(full); - var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions); - return (tree, compilation, compilation.GetSemanticModel(tree), prefix.Length); + var compilation = CSharpCompilation.Create( + "ScriptAnalysis", new[] { WrapAndParse("", CancellationToken.None) }, Sandbox.References, CompileOptions); + _ = compilation.GetSpecialType(SpecialType.System_Object); + return compilation; } + // userSource is appended after Preamble, then the method/class are closed. Every analysis derives straight + // from the shared BaseCompilation: the analysis endpoints are minimal-API handlers, so a scoped service lives + // for one HTTP request and a per-instance lineage would never be more than one step long. + private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze( + string userSource, CancellationToken ct) + { + var tree = WrapAndParse(userSource, ct); + var compilation = BaseCompilation.ReplaceSyntaxTree(BaseCompilation.SyntaxTrees[0], tree); + return (tree, compilation, compilation.GetSemanticModel(tree), Preamble.Length); + } + + /// Test seam: the compilation one analysis of runs against. + internal CSharpCompilation CompilationFor(string userSource) => Analyze(Normalize(userSource), CancellationToken.None).Compilation; + // editor (line,col) in USER-source coords -> physical offset in the wrapped document private static int OffsetInWrapped(string userSource, int line, int column, int preambleLength) => preambleLength + Math.Clamp(PositionToOffset(userSource ?? "", line, column), 0, (userSource ?? "").Length); @@ -87,14 +112,16 @@ public sealed class ScriptAnalysisService /// Compiles the script and returns compile-error diagnostics plus sandbox-policy violations /// (forbidden types, disallowed dynamic tag paths). /// The diagnose request carrying the script source. + /// The request's cancellation token; a cancelled request throws . /// The list of diagnostic markers, empty when the script is clean or analysis fails. - public DiagnoseResponse Diagnose(DiagnoseRequest req) + public DiagnoseResponse Diagnose(DiagnoseRequest req, CancellationToken ct = default) { if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty()); try { + ct.ThrowIfCancellationRequested(); var code = Normalize(req.Code); - var (tree, compilation, _, preambleLength) = Analyze(code); + var (tree, compilation, _, preambleLength) = Analyze(code, ct); var markers = new List(); // (1) Roslyn — surface ONLY compile errors, the same severity that actually blocks a @@ -107,7 +134,7 @@ public sealed class ScriptAnalysisService // makes GetMappedLineSpan report user-source coords (GetLineSpan would return the // physical wrapper line and be wrong); the preambleLength guard also drops phantom // wrapper diagnostics like CS0161 on Run() when the user hasn't typed `return` yet. - foreach (var d in compilation.GetDiagnostics()) + foreach (var d in compilation.GetDiagnostics(ct)) { if (d.Severity != DiagnosticSeverity.Error || !d.Location.IsInSource) continue; if (d.Location.SourceSpan.Start < preambleLength) continue; @@ -115,13 +142,23 @@ public sealed class ScriptAnalysisService markers.Add(ToMarker(span, Sev(d.Severity), d.GetMessage(), d.Id)); } // (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; map via #line. + ct.ThrowIfCancellationRequested(); foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation)) markers.Add(ToMarker(tree.GetMappedLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN")); + ct.ThrowIfCancellationRequested(); // (3) DependencyExtractor — spans are offsets in the normalized user source; map directly. foreach (var r in DependencyExtractor.Extract(code).Rejections) markers.Add(ToUserMarker(code, r.Span, r.Message, "OTSCRIPT_DYNPATH")); + // (4) ctx.SetVirtualTag(...) is inert at runtime — a WARNING (severity 4), never an error: publish + // accepts the script, so the editor must too; the marker just puts the hover's caveat in the Problems list. + foreach (var inv in SetVirtualTagCalls(tree.GetRoot(ct), preambleLength)) + markers.Add(ToMarker(tree.GetMappedLineSpan(inv.Span), 4, SetVirtualTagInertMessage, "OTSCRIPT_SETVTAG_INERT")); - return new DiagnoseResponse(markers.Distinct().ToList()); + return new DiagnoseResponse(Bound(markers)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; } catch (Exception ex) { @@ -144,10 +181,11 @@ public sealed class ScriptAnalysisService /// (DraftValidator.ValidateEquipReferenceResolution) rejects, so the editor accepts ⇔ publish /// accepts. Inert (base markers only) on the shared ScriptEdit page where EquipmentId is null. /// The diagnose request (its EquipmentId carries the owning-equipment context). + /// The request's cancellation token; a cancelled request throws . /// The base diagnostics plus any unresolved-reference markers. - public async Task DiagnoseAsync(DiagnoseRequest req) + public async Task DiagnoseAsync(DiagnoseRequest req, CancellationToken ct = default) { - var baseResp = Diagnose(req); + var baseResp = Diagnose(req, ct); if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code)) return baseResp; try @@ -156,7 +194,7 @@ public sealed class ScriptAnalysisService if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) return baseResp; - var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None); + var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, ct); var resolvable = new HashSet(names, StringComparer.Ordinal); var markers = new List(baseResp.Markers); @@ -173,7 +211,11 @@ public sealed class ScriptAnalysisService $"reference named '{refName}'. Add a matching reference or correct the path.", "OTSCRIPT_EQUIPREF")); } - return new DiagnoseResponse(markers.Distinct().ToList()); + return new DiagnoseResponse(Bound(markers)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; } catch (Exception ex) { @@ -182,6 +224,35 @@ public sealed class ScriptAnalysisService } } + // Bounds what the diagnostics endpoint returns (and so what Monaco renders and the Problems list is fed from): + // de-duplicated, errors first then by position, at most MaxDiagnosticMarkers, each message cut to + // MaxDiagnosticMessageChars. Ordering before the cut keeps an error whenever one exists, so the editor still + // rejects exactly what publish rejects even for a pathological (e.g. non-C#) paste. + private static IReadOnlyList Bound(IEnumerable markers) + => ScriptProblems.Order(markers.Distinct()) + .Take(ScriptEditorLimits.MaxDiagnosticMarkers) + .Select(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars + ? m + : m with { Message = ScriptProblems.Truncate(m.Message, ScriptEditorLimits.MaxDiagnosticMessageChars) }) + .ToList(); + + private const string SetVirtualTagInertMessage = + "ctx.SetVirtualTag(...) has no effect at runtime: cross-tag writes are dropped in single-tag mode (the " + + "cascade engine is not wired into the host). The script compiles and publishes, but this write will not " + + "take effect."; + + // Every ctx.SetVirtualTag(...) invocation in the user source — the same `ctx`-anchored receiver guard as the + // tag-path completion (TryGetTagPathLiteral), so comments, string literals and other receivers are ignored. + private static IEnumerable SetVirtualTagCalls(SyntaxNode root, int preambleLength) + => root.DescendantNodes() + .OfType() + .Where(inv => inv.SpanStart >= preambleLength + && inv.Expression is MemberAccessExpressionSyntax + { + Expression: IdentifierNameSyntax { Identifier.ValueText: "ctx" }, + Name.Identifier.ValueText: nameof(ScriptContext.SetVirtualTag), + }); + private static int Sev(DiagnosticSeverity s) => s switch { DiagnosticSeverity.Error => 8, @@ -210,16 +281,18 @@ public sealed class ScriptAnalysisService } /// Computes completion items (tag-path, dot-member, or in-scope symbol completions) at the caret position. /// The completions request carrying the script source and caret position. + /// The request's cancellation token; a cancelled request throws . /// The resolved list of completion items, empty when none apply. - public async Task CompleteAsync(CompletionsRequest req) + public async Task CompleteAsync(CompletionsRequest req, CancellationToken ct = default) { if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty()); try { + ct.ThrowIfCancellationRequested(); var code = Normalize(req.CodeText); - var (tree, _, model, preambleLength) = Analyze(code); + var (tree, _, model, preambleLength) = Analyze(code, ct); var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength); - var root = await tree.GetRootAsync(); + var root = await tree.GetRootAsync(ct); // position is the offset just AFTER the caret; -1 finds the token the caret sits at/just-after (e.g. the dot in "ctx."). var token = root.FindToken(Math.Max(0, position - 1)); @@ -233,26 +306,31 @@ public sealed class ScriptAnalysisService if (string.IsNullOrEmpty(req.EquipmentId)) return new CompletionsResponse(Array.Empty()); var names = await _catalog.GetEquipmentReferenceNamesAsync( - req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None); + req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), ct); return new CompletionsResponse(names .Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field")) .ToList()); } - var paths = await _catalog.GetPathsAsync(pathPrefix, CancellationToken.None); + var paths = await _catalog.GetPathsAsync(pathPrefix, ct); return new CompletionsResponse( paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList()); } - var dot = TryGetDotMembers(token, model); + var dot = TryGetDotMembers(token, model, ct); if (dot != null) return new CompletionsResponse(dot); + ct.ThrowIfCancellationRequested(); var scoped = model.LookupSymbols(position) .Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name)) .GroupBy(s => s.Name).Select(g => g.First()) .Select(ToCompletionItem).Take(200).ToList(); return new CompletionsResponse(scoped); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } catch (Exception ex) { _logger?.LogWarning(ex, "Script completion failed; returning none."); @@ -260,12 +338,12 @@ public sealed class ScriptAnalysisService } } - private static List? TryGetDotMembers(SyntaxToken token, SemanticModel model) + private static List? TryGetDotMembers(SyntaxToken token, SemanticModel model, CancellationToken ct) { var memberAccess = token.Parent as MemberAccessExpressionSyntax ?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax; if (memberAccess == null) return null; - var typeInfo = model.GetTypeInfo(memberAccess.Expression); + var typeInfo = model.GetTypeInfo(memberAccess.Expression, ct); var type = typeInfo.Type ?? typeInfo.ConvertedType; if (type == null) return null; // The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose @@ -322,16 +400,18 @@ public sealed class ScriptAnalysisService } /// Computes hover information (tag-path info or C# symbol info) for the position under the caret. /// The hover request carrying the script source and caret position. + /// The request's cancellation token; a cancelled request throws . /// The resolved hover markdown, or a response with a null value when nothing resolves. - public async Task Hover(HoverRequest req) + public async Task Hover(HoverRequest req, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(req.CodeText)) return new HoverResponse(null); try { + ct.ThrowIfCancellationRequested(); var code = Normalize(req.CodeText); - var (tree, _, model, preambleLength) = Analyze(code); + var (tree, _, model, preambleLength) = Analyze(code, ct); var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength); - var token = tree.GetRoot().FindToken(Math.Max(0, position - 1)); + var token = tree.GetRoot(ct).FindToken(Math.Max(0, position - 1)); // Tag-path hover takes priority over C# symbol resolution: when the caret sits on a // ctx.GetTag("…")/ctx.SetVirtualTag("…") path literal, show the resolved tag's info @@ -350,7 +430,7 @@ public sealed class ScriptAnalysisService "(by effective name) to the backing raw tag's path when the VirtualTag is deployed."); } - var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None); + var info = await _catalog.GetTagInfoAsync(tagPath, ct); var tagMd = info is null ? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path." : $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**" @@ -368,21 +448,22 @@ public sealed class ScriptAnalysisService // Resolve the token's node; if that yields nothing, climb to the enclosing // member-access (e.g. hovering the `GetTag` identifier — the IdentifierName itself // carries no symbol info, but its parent member-access binds to the method). - var symbol = ResolveSymbol(model, node); + var symbol = ResolveSymbol(model, node, ct); if (symbol is null) return new HoverResponse(null); var display = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); var summary = TryGetXmlSummary(symbol); var md = "```csharp\n" + display + "\n```" + (summary is null ? "" : "\n\n" + summary); return new HoverResponse(md); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { _logger?.LogWarning(ex, "Script hover failed."); return new HoverResponse(null); } } - private static ISymbol? ResolveSymbol(SemanticModel model, SyntaxNode node) + private static ISymbol? ResolveSymbol(SemanticModel model, SyntaxNode node, CancellationToken ct) { foreach (var candidate in ResolutionCandidates(node)) { - var info = model.GetSymbolInfo(candidate); + var info = model.GetSymbolInfo(candidate, ct); var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); if (symbol is not null) return symbol; } @@ -420,24 +501,26 @@ public sealed class ScriptAnalysisService /// Computes signature help for the method invocation at the caret position. /// The signature-help request carrying the script source and caret position. + /// The request's cancellation token; a cancelled request throws . /// The resolved signature and active-parameter index, or an empty response when none applies. - public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req) + public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req, CancellationToken ct = default) { var empty = new SignatureHelpResponse(null, null, 0); if (string.IsNullOrWhiteSpace(req.CodeText)) return empty; try { + ct.ThrowIfCancellationRequested(); var code = Normalize(req.CodeText); - var (tree, _, model, preambleLength) = Analyze(code); + var (tree, _, model, preambleLength) = Analyze(code, ct); var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength); - var token = tree.GetRoot().FindToken(Math.Max(0, position - 1)); + var token = tree.GetRoot(ct).FindToken(Math.Max(0, position - 1)); InvocationExpressionSyntax? inv = null; for (var n = token.Parent; n is not null; n = n.Parent) if (n is InvocationExpressionSyntax found) { inv = found; break; } if (inv is null) return empty; - var info = model.GetSymbolInfo(inv); + var info = model.GetSymbolInfo(inv, ct); var method = (info.Symbol ?? info.CandidateSymbols.FirstOrDefault()) as IMethodSymbol; if (method is null) return empty; @@ -453,6 +536,7 @@ public sealed class ScriptAnalysisService return new SignatureHelpResponse(label, ps, active); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { _logger?.LogWarning(ex, "Script signature help failed."); return empty; } } /// Formats a script's source code using Roslyn's syntax-tree whitespace normalization. @@ -474,9 +558,4 @@ public sealed class ScriptAnalysisService return new FormatResponse(req.Code); } } - /// Returns editor inlay hints for the script. Currently a no-op (always empty) — the endpoint - /// exists for future use but the feature is not implemented. - /// The inlay-hints request (unused). - /// An always-empty inlay-hints response. - public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty()); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs new file mode 100644 index 00000000..96fcbe66 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptEditorLimits.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +/// Size limits for moving Monaco editor text across the Blazor Server circuit. +/// +/// The editor text crosses JS → .NET only on demand (at blur and when a page saves), never per keystroke. Each +/// crossing is ONE SignalR message, bounded by the Blazor hub's MaximumReceiveMessageSize. The framework +/// default is 32 KB, which silently lost any larger script (the oversize invoke failed and Save persisted the +/// stale source), so AddAdminUI raises it to . The editor JS +/// refuses to send a value whose serialized form exceeds — an oversize frame +/// would tear the circuit down — and the save surfaces that refusal to the author instead. +/// +public static class ScriptEditorLimits +{ + /// The Blazor hub's receive limit, in bytes (1 MB). + public const long HubMaximumReceiveMessageSize = 1024 * 1024; + + /// The largest serialized editor value the JS will send, leaving 64 KB of frame headroom. + public const int MaxEditorSyncBytes = (int)HubMaximumReceiveMessageSize - 64 * 1024; + + /// + /// The most diagnostic markers the editor reports to .NET (for the Problems list) in one circuit message. + /// A non-C# paste produces several Roslyn errors per line; uncapped, a few thousand markers exceed the hub + /// limit and tear the circuit down. The JS sends the first N in display order (errors first) plus the total. + /// Monaco's own in-editor squiggles are local and stay uncapped (up to ). + /// + public const int MaxReportedMarkers = 200; + + /// Each reported marker's message is truncated to this many characters before it crosses the circuit. + public const int MaxReportedMessageChars = 300; + + /// + /// The most markers the diagnostics endpoint returns (errors first). Bounds the analysis response and the + /// marker set Monaco renders for pathological input; an error is always kept when one exists, so the + /// editor still rejects exactly what publish rejects. + /// + public const int MaxDiagnosticMarkers = 1000; + + /// Each diagnostics-endpoint marker message is truncated to this many characters (a Roslyn message + /// can quote an arbitrarily long pasted token). + public const int MaxDiagnosticMessageChars = 1000; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs new file mode 100644 index 00000000..5d3969ae --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptProblems.cs @@ -0,0 +1,103 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +/// +/// Presentation rules for the script editor's Problems list — the markers Monaco reports through +/// MonacoEditor.MarkersChanged (the same shape the diagnostics endpoint +/// returns), rendered by ScriptProblemsList. +/// +public static class ScriptProblems +{ + /// Monaco's error severity. + public const int Error = 8; + + /// Monaco's warning severity. + public const int Warning = 4; + + /// Errors first, then warnings, then the rest; within a severity, by position. + /// The reported markers (null treated as none). + /// The markers in display order. + public static IReadOnlyList Order(IEnumerable? markers) + => (markers ?? Array.Empty()) + .OrderByDescending(m => m.Severity) + .ThenBy(m => m.StartLineNumber) + .ThenBy(m => m.StartColumn) + .ToList(); + + /// The label for a Monaco severity value. + /// 8 = error, 4 = warning, 2 = info, otherwise hint. + /// The display label. + public static string SeverityLabel(int severity) => severity switch + { + Error => "Error", + Warning => "Warning", + 2 => "Info", + _ => "Hint", + }; + + /// A one-line count, e.g. "2 errors, 1 warning". + /// The reported markers. + /// The summary, or "No problems". + public static string Summary(IReadOnlyCollection markers) + { + if (markers.Count == 0) return "No problems"; + var errors = markers.Count(m => m.Severity == Error); + var warnings = markers.Count(m => m.Severity == Warning); + var other = markers.Count - errors - warnings; + var parts = new List(3); + if (errors > 0) parts.Add(Plural(errors, "error")); + if (warnings > 0) parts.Add(Plural(warnings, "warning")); + if (other > 0) parts.Add($"{other} other"); + return string.Join(", ", parts); + } + + /// A one-line count for a (possibly capped) report, e.g. "200 errors (showing 200 of 4321)". + /// The report the editor sent. + /// The summary of the shown markers, with the shown/total suffix when some were left out. + public static string Summary(ScriptProblemReport report) + { + var summary = Summary(report.Markers.ToList()); + return report.Omitted > 0 ? $"{summary} (showing {report.Markers.Count} of {report.Total})" : summary; + } + + /// Cuts to at most characters, ending in an ellipsis. + /// The text. + /// The maximum length, ellipsis included (at least 1). + /// The text, unchanged when it already fits. + public static string Truncate(string text, int maxChars) + => text.Length <= maxChars ? text : string.Concat(text.AsSpan(0, Math.Max(0, maxChars - 1)), "…"); + + private static string Plural(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s"; +} + +/// +/// What the editor reported for the Problems list: at most +/// markers in display order (errors first) plus the the diagnostics pass produced. The JS caps +/// the report before it crosses the circuit — one hub message, bounded by the 1 MB receive limit — and +/// clamps it again on arrival. +/// +/// The markers shown. +/// How many markers the diagnostics pass produced (≥ Markers.Count). +public sealed record ScriptProblemReport(IReadOnlyList Markers, int Total) +{ + /// No problems. + public static readonly ScriptProblemReport Empty = new(Array.Empty(), 0); + + /// How many markers were left out of . + public int Omitted => Math.Max(0, Total - Markers.Count); + + /// Builds a report from what the editor JS sent, re-applying the circuit caps defensively. + /// The markers sent (already capped by the JS). + /// The total the JS counted. + /// The clamped report. + public static ScriptProblemReport FromEditor(IReadOnlyList? markers, int total) + { + if (markers is null || markers.Count == 0) return total <= 0 ? Empty : new(Array.Empty(), total); + var shown = ScriptProblems.Order(markers) + .Take(ScriptEditorLimits.MaxReportedMarkers) + .Select(m => m.Message.Length <= ScriptEditorLimits.MaxReportedMessageChars + ? m + : m with { Message = ScriptProblems.Truncate(m.Message, ScriptEditorLimits.MaxReportedMessageChars) }) + .ToList(); + return new ScriptProblemReport(shown, Math.Max(total, markers.Count)); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogIndex.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogIndex.cs new file mode 100644 index 00000000..2f4016ed --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogIndex.cs @@ -0,0 +1,82 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +/// +/// The query-ready form of one snapshot: the raw entries, a sorted +/// de-duplicated path array for prefix completion, and an Ordinal path→info dictionary for hover. +/// +/// +/// Built ONCE per snapshot (at most every 5 s) instead of per keystroke. Completion used to build a +/// of every fleet path and sort it on each request, and hover scanned the entries +/// linearly; on a 50k-tag fleet that was tens of MB of garbage per typed character. The semantics are +/// unchanged: completion is case-insensitive StartsWith, sorted , +/// with case-only duplicates collapsed to the first-seen casing; hover is an exact Ordinal match (the runtime +/// dependency mux is Ordinal) returning the first entry for a path. +/// +public sealed class ScriptTagCatalogIndex +{ + private static readonly StringComparer SortComparer = StringComparer.OrdinalIgnoreCase; + + private readonly Dictionary _byPath; + private readonly string[] _sortedPaths; + + private ScriptTagCatalogIndex(IReadOnlyList entries, string[] sortedPaths, Dictionary byPath) + { + Entries = entries; + _sortedPaths = sortedPaths; + _byPath = byPath; + } + + /// The projection the index was built from. + public IReadOnlyList Entries { get; } + + /// Distinct (case-insensitively) paths, sorted . + public IReadOnlyList SortedPaths => _sortedPaths; + + /// Builds the index over a catalog projection. + /// The catalog entries, in projection order (first occurrence wins on duplicates). + /// The built index. + public static ScriptTagCatalogIndex Build(IReadOnlyList entries) + { + // A stable sort keeps projection order among case-only duplicates, so keeping the first of each run + // reproduces the first-added casing a HashSet(OrdinalIgnoreCase) kept. + var sorted = new List(entries.Count); + foreach (var path in entries.Select(e => e.Path).OrderBy(p => p, SortComparer)) + { + if (sorted.Count > 0 && SortComparer.Equals(sorted[^1], path)) continue; + sorted.Add(path); + } + + var byPath = new Dictionary(entries.Count, StringComparer.Ordinal); + foreach (var e in entries) byPath.TryAdd(e.Path, e); + + return new ScriptTagCatalogIndex(entries, sorted.ToArray(), byPath); + } + + /// Paths starting (case-insensitively) with , in sorted order. + /// The literal prefix; null/whitespace returns the first paths. + /// Upper bound on the returned count. + /// At most matching paths. + public IReadOnlyList FindByPrefix(string? prefix, int max) + { + var paths = _sortedPaths; + if (string.IsNullOrWhiteSpace(prefix)) return paths.Take(max).ToArray(); + + // Under an ordinal-style (char-by-char) comparer every string with prefix P sorts at or after P and the + // matches form one contiguous run, so a lower-bound binary search plus a forward scan is exact. + var start = Array.BinarySearch(paths, prefix, SortComparer); + if (start < 0) start = ~start; + + var result = new List(Math.Min(max, 32)); + for (var i = start; i < paths.Length && result.Count < max; i++) + { + if (!paths[i].StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) break; + result.Add(paths[i]); + } + return result; + } + + /// Exact (Ordinal) lookup of a path. + /// The path to look up. + /// The first entry for that path, or . + public ScriptTagInfo? TryGet(string path) => _byPath.GetValueOrDefault(path); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs index c6597fae..1369c52e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs @@ -55,24 +55,37 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider) public async Task> GetOrBuildAsync( Func>> build, CancellationToken ct) + => (await GetOrBuildIndexAsync(build, ct)).Entries; + + /// + /// As , but returns the snapshot's query-ready + /// (sorted paths + path dictionary), which is built once per rebuild + /// rather than once per completion keystroke. + /// + /// Builds a fresh projection; invoked at most once per TTL window. + /// Cancellation token. + /// The index over a fresh snapshot, or over one just built. + public async Task GetOrBuildIndexAsync( + Func>> build, + CancellationToken ct) { var current = _snapshot; - if (current is not null && !IsStale(current)) return current.Entries; + if (current is not null && !IsStale(current)) return current.Index; await _rebuildGate.WaitAsync(ct); try { // Double-check: while we queued, the caller ahead of us may have rebuilt already. current = _snapshot; - if (current is not null && !IsStale(current)) return current.Entries; + if (current is not null && !IsStale(current)) return current.Index; // A throw here leaves _snapshot untouched (the previous one, if any, keeps serving) and // propagates to this caller only; the finally still releases the gate. - var entries = await build(ct); + var index = ScriptTagCatalogIndex.Build(await build(ct)); // Stamped AFTER the build so a slow scan does not hand back an already-expired snapshot. - _snapshot = new Snapshot(entries, timeProvider.GetUtcNow()); - return entries; + _snapshot = new Snapshot(index, timeProvider.GetUtcNow()); + return index; } finally { @@ -82,5 +95,5 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider) private bool IsStale(Snapshot snapshot) => timeProvider.GetUtcNow() - snapshot.TakenAt >= Ttl; - private sealed record Snapshot(IReadOnlyList Entries, DateTimeOffset TakenAt); + private sealed record Snapshot(ScriptTagCatalogIndex Index, DateTimeOffset TakenAt); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js index e90c63bc..e5fc3f0e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js @@ -23,6 +23,30 @@ const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 }; + // POSTs to a script-analysis endpoint, honouring Monaco's CancellationToken: when Monaco abandons a request + // (the caret moved, the user kept typing) the fetch is aborted, which cancels HttpContext.RequestAborted + // server-side so the Roslyn analysis stops instead of running to completion. Resolves to null when the + // request was cancelled or failed, so a provider never applies a superseded answer. + async function postAnalysis(path, body, token) { + if (token && token.isCancellationRequested) return null; + const ac = new AbortController(); + const sub = token && token.onCancellationRequested ? token.onCancellationRequested(function () { ac.abort(); }) : null; + try { + const resp = await fetch("/api/script-analysis/" + path, { + method: "POST", credentials: "same-origin", signal: ac.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + if (!resp.ok) return null; + const data = await resp.json(); + return (token && token.isCancellationRequested) ? null : data; + } catch (e) { + return null; + } finally { + if (sub && sub.dispose) sub.dispose(); + } + } + // The owning-equipment id is stamped onto each editor's model at createEditor time so the globally // registered csharp providers (which only receive the model) can thread it into the {{equip}}/ // completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there). @@ -31,15 +55,12 @@ function registerCSharpProviders() { monaco.languages.registerCompletionItemProvider("csharp", { triggerCharacters: [".", "(", "\""], - provideCompletionItems: async function (model, position) { + provideCompletionItems: async function (model, position, context, token) { try { - const resp = await fetch("/api/script-analysis/completions", { - method: "POST", credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) }) - }); - if (!resp.ok) return { suggestions: [] }; - const data = await resp.json(); + const data = await postAnalysis("completions", + { codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) }, + token); + if (!data) return { suggestions: [] }; const word = model.getWordUntilPosition(position); const wordRange = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, @@ -72,67 +93,34 @@ } }); monaco.languages.registerHoverProvider("csharp", { - provideHover: async function (model, position) { + provideHover: async function (model, position, token) { try { - const resp = await fetch("/api/script-analysis/hover", { - method: "POST", credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) }) - }); - if (!resp.ok) return null; - const data = await resp.json(); - if (!data.markdown) return null; + const data = await postAnalysis("hover", + { codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) }, + token); + if (!data || !data.markdown) return null; return { contents: [{ value: data.markdown }] }; } catch (e) { return null; } } }); monaco.languages.registerDocumentFormattingEditProvider("csharp", { - provideDocumentFormattingEdits: async function (model) { + provideDocumentFormattingEdits: async function (model, options, token) { try { - const resp = await fetch("/api/script-analysis/format", { - method: "POST", credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: model.getValue() }) - }); - if (!resp.ok) return []; - const data = await resp.json(); - if (typeof data.code !== "string" || data.code === model.getValue()) return []; + const data = await postAnalysis("format", { code: model.getValue() }, token); + if (!data || typeof data.code !== "string" || data.code === model.getValue()) return []; return [{ range: model.getFullModelRange(), text: data.code }]; } catch (e) { return []; } } }); - monaco.languages.registerInlayHintsProvider("csharp", { - provideInlayHints: async function (model) { - try { - const resp = await fetch("/api/script-analysis/inlay-hints", { - method: "POST", credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: model.getValue() }) - }); - if (!resp.ok) return { hints: [], dispose: function () {} }; - const data = await resp.json(); - return { - hints: (data.hints || []).map(function (h) { - return { position: { lineNumber: h.line, column: h.column }, label: h.label, kind: 2, paddingRight: true }; - }), - dispose: function () {} - }; - } catch (e) { return { hints: [], dispose: function () {} }; } - } - }); monaco.languages.registerSignatureHelpProvider("csharp", { signatureHelpTriggerCharacters: ["(", ","], signatureHelpRetriggerCharacters: [","], - provideSignatureHelp: async function (model, position) { + provideSignatureHelp: async function (model, position, token) { try { - const resp = await fetch("/api/script-analysis/signature-help", { - method: "POST", credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column }) - }); - if (!resp.ok) return null; - const data = await resp.json(); - if (!data.label) return null; + const data = await postAnalysis("signature-help", + { codeText: model.getValue(), line: position.lineNumber, column: position.column }, + token); + if (!data || !data.label) return null; return { value: { signatures: [{ @@ -149,17 +137,74 @@ }); } - async function fetchDiagnostics(model) { + // Resolves to the marker array, or null when the request was aborted (superseded by a newer edit) — the + // caller must then leave the markers alone. A server/network failure still resolves to [] (clears markers). + async function fetchDiagnostics(model, signal) { try { const resp = await fetch("/api/script-analysis/diagnostics", { - method: "POST", credentials: "same-origin", + method: "POST", credentials: "same-origin", signal: signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return []; const data = await resp.json(); return data.markers || []; - } catch (e) { return []; } + } catch (e) { return (signal && signal.aborted) ? null : []; } + } + + // The editor text crosses the Blazor circuit (JS -> .NET) only on demand — at blur and when the page saves — + // never per keystroke. Each crossing is one SignalR message, bounded by the hub's MaximumReceiveMessageSize; + // an oversize message would tear the circuit down, so a value whose serialized form would not fit is refused + // here instead (getValue throws a descriptive error that .NET surfaces to the author). + // Blazor carries an interop value as a JSON string inside the hub frame; measuring it escaped twice is a + // conservative upper bound whichever hub protocol (BlazorPack or JSON) the circuit negotiated. + function serializedBytes(v) { return new TextEncoder().encode(JSON.stringify(JSON.stringify(v))).length; } + function tooLargeMessage(e, v) { + if (!e.maxSyncBytes) return null; + const bytes = serializedBytes(v); + return bytes > e.maxSyncBytes + ? "The script is too large for the editor to send to the server (" + bytes + " bytes; the limit is " + e.maxSyncBytes + " bytes)." + : null; + } + function syncValue(e) { + if (!e.dirty) return; + const v = e.editor.getValue(); + if (tooLargeMessage(e, v)) { + // Stays dirty; the explicit save read reports the error. Tell .NET once (a tiny message) so the + // author sees a non-blocking notice now rather than only at Save. + if (!e.tooLargeNotified) { + e.tooLargeNotified = true; + e.dotNetRef.invokeMethodAsync("OnSyncRefused", serializedBytes(v)).catch(function () {}); + } + return; + } + if (e.tooLargeNotified) { + e.tooLargeNotified = false; + e.dotNetRef.invokeMethodAsync("OnSyncRefused", 0).catch(function () {}); + } + e.dirty = false; + e.dotNetRef.invokeMethodAsync("OnValueChanged", v).catch(function () { e.dirty = true; }); + } + + // The Problems-list report is ONE circuit message too. A non-C# paste yields thousands of markers, so only the + // first maxReportedMarkers in display order (errors first, then by position — ScriptProblems.Order) cross, each + // message cut to maxReportedMessageChars, plus the total so the list can say how many were left out. + // Monaco's own squiggles (setModelMarkers) are local and stay uncapped. + function severityOrder(a, b) { + return (b.severity - a.severity) || (a.startLineNumber - b.startLineNumber) || (a.startColumn - b.startColumn); + } + function markerReport(e, markers) { + const max = e.maxReportedMarkers || 200, maxChars = e.maxReportedMessageChars || 300; + const shown = markers.slice().sort(severityOrder).slice(0, max).map(function (m) { + const msg = typeof m.message === "string" && m.message.length > maxChars + ? m.message.substring(0, maxChars - 1) + "\u2026" : m.message; + return { + severity: m.severity, startLineNumber: m.startLineNumber, startColumn: m.startColumn, + endLineNumber: m.endLineNumber, endColumn: m.endColumn, message: msg, + code: typeof m.code === "string" ? m.code.substring(0, 64) : m.code + }; + }); + return { markers: shown, total: markers.length }; } // A single body-level node for Monaco's overflow widgets (suggest / hover / parameter hints). @@ -200,22 +245,43 @@ // Stamp the owning-equipment id on the model so the global csharp providers can thread it into the // {{equip}}/ completion + diagnostics (null on the shared ScriptEdit page). try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {} - let diagTimer = null; - const scheduleDiagnostics = function () { - if (diagTimer) clearTimeout(diagTimer); - diagTimer = setTimeout(async function () { - const model = editor.getModel(); if (!model) return; - const markers = await fetchDiagnostics(model); - monaco.editor.setModelMarkers(model, "otopcua", markers); - dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {}); - }, 500); + const e = { + editor: editor, dotNetRef: dotNetRef, dirty: false, + maxSyncBytes: options.maxSyncBytes || 0, reportMarkers: !!options.reportMarkers, + maxReportedMarkers: options.maxReportedMarkers || 200, maxReportedMessageChars: options.maxReportedMessageChars || 300, + tooLargeNotified: false, + diagTimer: null, diagGeneration: 0, diagAbort: null + }; + // Diagnostics: the 500 ms debounce delays the START of a request; the generation counter + AbortController + // make sure only the response for the CURRENT text is applied. Every edit bumps the generation and aborts + // the in-flight request, so a slow older response can never overwrite newer markers. + const runDiagnostics = async function () { + const model = editor.getModel(); if (!model) return; + const generation = ++e.diagGeneration; + const ac = new AbortController(); + e.diagAbort = ac; + const markers = await fetchDiagnostics(model, ac.signal); + if (markers === null || generation !== e.diagGeneration || editors[id] !== e) return; // superseded + e.diagAbort = null; + monaco.editor.setModelMarkers(model, "otopcua", markers); + if (e.reportMarkers) { + const report = markerReport(e, markers); + dotNetRef.invokeMethodAsync("OnMarkersChanged", report.markers, report.total).catch(function () {}); + } + }; + e.scheduleDiagnostics = function () { + e.diagGeneration++; + if (e.diagAbort) { e.diagAbort.abort(); e.diagAbort = null; } + if (e.diagTimer) clearTimeout(e.diagTimer); + e.diagTimer = setTimeout(runDiagnostics, 500); }; editor.onDidChangeModelContent(function () { - dotNetRef.invokeMethodAsync("OnValueChanged", editor.getValue()).catch(function () {}); - if (options.language === "csharp") scheduleDiagnostics(); + e.dirty = true; + if (options.language === "csharp") e.scheduleDiagnostics(); }); - editors[id] = { editor: editor, dotNetRef: dotNetRef }; - if (options.language === "csharp") scheduleDiagnostics(); + editor.onDidBlurEditorText(function () { syncValue(e); }); + editors[id] = e; + if (options.language === "csharp") e.scheduleDiagnostics(); } function setEditorOption(id, name, value) { const e = editors[id]; if (!e) return; @@ -228,10 +294,28 @@ const e = editors[id]; if (!e) return; e.editor.revealLineInCenter(line); e.editor.setPosition({ lineNumber: line, column: col || 1 }); e.editor.focus(); } - function setValue(id, v) { const e = editors[id]; if (e && e.editor.getValue() !== v) e.editor.setValue(v || ""); } - function getValue(id) { const e = editors[id]; return e ? e.editor.getValue() : null; } + function setValue(id, v) { + const e = editors[id]; if (!e) return; + if (e.editor.getValue() !== v) e.editor.setValue(v || ""); + e.dirty = false; // the text now equals what .NET holds + } + // Reads the current text on demand (Save). Throws when it would not fit in one circuit message. + function getValue(id) { + const e = editors[id]; if (!e) return null; + const v = e.editor.getValue(); + const err = tooLargeMessage(e, v); + if (err) throw new Error(err); + e.dirty = false; + return v; + } function setMarkers(id, m) { const e = editors[id]; const model = e && e.editor.getModel(); if (model) monaco.editor.setModelMarkers(model, "otopcua", m || []); } - function dispose(id) { const e = editors[id]; if (!e) return; try { e.editor.dispose(); } catch (x) {} delete editors[id]; } + function dispose(id) { + const e = editors[id]; if (!e) return; + if (e.diagTimer) clearTimeout(e.diagTimer); + if (e.diagAbort) e.diagAbort.abort(); + try { e.editor.dispose(); } catch (x) {} + delete editors[id]; + } window.MonacoBlazor = { createEditor, setValue, getValue, setMarkers, setEditorOption, format, revealLine, dispose }; })(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCancellationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCancellationTests.cs new file mode 100644 index 00000000..2253d8f6 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCancellationTests.cs @@ -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; + +/// +/// 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 HttpContext.RequestAborted. +/// +public sealed class AnalysisCancellationTests +{ + internal sealed class CapturingCatalog : IScriptTagCatalog + { + public readonly List Seen = new(); + + public Task> GetPathsAsync(string? filter, CancellationToken ct) + { + Seen.Add(ct); + return Task.FromResult>(new[] { "Plant/Dev/Speed" }); + } + + public Task GetTagInfoAsync(string path, CancellationToken ct) + { + Seen.Add(ct); + return Task.FromResult(new ScriptTagInfo(path, "Tag", "Float", "DRV-1")); + } + + public Task> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct) + { + Seen.Add(ct); + return Task.FromResult>(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( + 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(svc.CompleteAsync(new CompletionsRequest(code, 1, 20), Cancelled())); + await Should.ThrowAsync( + svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), Cancelled())); + Should.Throw(() => 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(catalog); + builder.Services.AddScoped(sp => new ScriptAnalysisService(sp.GetRequiredService())); + builder.Services.AddAuthorization(); + var app = builder.Build(); + app.MapScriptAnalysisEndpoints(); + return (app, catalog); + } + + internal static IReadOnlyList Endpoints(WebApplication app) + => ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType().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(new HasBody()); + + await endpoint.RequestDelegate!(ctx); + + ctx.Response.StatusCode.ShouldBe(200); + catalog.Seen.ShouldHaveSingleItem().ShouldBe(aborted.Token); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCompilationReuseTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCompilationReuseTests.cs new file mode 100644 index 00000000..d6eaca6a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCompilationReuseTests.cs @@ -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; + +/// +/// R04:P2 — the editor used to build a brand-new CSharpCompilation (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 ReplaceSyntaxTree, so the referenced-assembly +/// binding is done once and shared. +/// +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); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MarkerPayloadBoundTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MarkerPayloadBoundTests.cs new file mode 100644 index 00000000..478395aa --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MarkerPayloadBoundTests.cs @@ -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; + +/// +/// 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. +/// +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); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MonacoInteropSourceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MonacoInteropSourceTests.cs new file mode 100644 index 00000000..179ec723 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/MonacoInteropSourceTests.cs @@ -0,0 +1,104 @@ +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; + +/// +/// 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. +/// +[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 \(\) \{(?.*?)\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(").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 + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ProblemsListTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ProblemsListTests.cs new file mode 100644 index 00000000..84c4deaf --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ProblemsListTests.cs @@ -0,0 +1,59 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; + +/// +/// 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. +/// +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()).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)); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogIndexTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogIndexTests.cs new file mode 100644 index 00000000..84ec3070 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogIndexTests.cs @@ -0,0 +1,79 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; + +/// +/// 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 string[] (prefix-searched +/// with a binary search) and an Ordinal path→info dictionary, built once per snapshot. +/// +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 Naive(IReadOnlyList entries, string? filter) + { + IEnumerable q = new HashSet(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 { 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); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/SetVirtualTagInertDiagnosticTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/SetVirtualTagInertDiagnosticTests.cs new file mode 100644 index 00000000..69cd2b57 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/SetVirtualTagInertDiagnosticTests.cs @@ -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; + +/// +/// R04:S1 — ctx.SetVirtualTag(...) compiles and publishes but is dropped at runtime (single-tag mode). +/// Only a hover used to say so; Diagnose now emits an OTSCRIPT_SETVTAG_INERT WARNING marker so the +/// Problems list shows it. A warning, never an error: publish accepts the script, so the editor must too. +/// +public sealed class SetVirtualTagInertDiagnosticTests +{ + private const string InertCode = "OTSCRIPT_SETVTAG_INERT"; + private static readonly ScriptAnalysisService Svc = new(); + private static IReadOnlyList 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.Compile(source); + evaluator.ShouldNotBeNull(); + } +}