Merge lane D-a: Monaco on-demand sync, sequenced diagnostics, bounded marker payload, Problems list (#745)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
@using System.Text
|
@using System.Text
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
@@ -30,7 +31,9 @@ else if (!IsNew && _existing is null)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="scriptEdit">
|
@* 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. *@
|
||||||
|
<EditForm Model="_form" OnSubmit="SubmitAsync" FormName="scriptEdit">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<ValidationSummary class="alert alert-danger py-2 px-3" />
|
<ValidationSummary class="alert alert-danger py-2 px-3" />
|
||||||
<section class="panel rise">
|
<section class="panel rise">
|
||||||
@@ -57,7 +60,9 @@ else
|
|||||||
<section class="panel rise mt-3">
|
<section class="panel rise mt-3">
|
||||||
<div class="panel-head">Source</div>
|
<div class="panel-head">Source</div>
|
||||||
<div style="padding:1rem">
|
<div style="padding:1rem">
|
||||||
<MonacoEditor @bind-Value="_form.SourceCode" Height="420px" />
|
<MonacoEditor @ref="_editor" @bind-Value="_form.SourceCode" Height="420px"
|
||||||
|
MarkersChanged="OnMarkersChanged" />
|
||||||
|
<ScriptProblemsList Report="_markers" OnSelect="RevealProblemAsync" />
|
||||||
<div class="form-text">SHA-256 hash is computed automatically on save.</div>
|
<div class="form-text">SHA-256 hash is computed automatically on save.</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -84,6 +89,13 @@ else
|
|||||||
private bool _loaded;
|
private bool _loaded;
|
||||||
private bool _busy;
|
private bool _busy;
|
||||||
private string? _error;
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -105,11 +117,21 @@ else
|
|||||||
_loaded = true;
|
_loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SubmitAsync()
|
private async Task SubmitAsync(EditContext editContext)
|
||||||
{
|
{
|
||||||
_busy = true; _error = null;
|
_busy = true; _error = null;
|
||||||
try
|
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);
|
var sourceHash = HashSource(_form.SourceCode);
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
if (IsNew)
|
if (IsNew)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
||||||
@using Microsoft.Extensions.Logging
|
@using Microsoft.Extensions.Logging
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
|
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
|
||||||
@@ -20,9 +21,23 @@
|
|||||||
|
|
||||||
<div @ref="_hostRef" class="monaco-editor-host"
|
<div @ref="_hostRef" class="monaco-editor-host"
|
||||||
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
|
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
|
||||||
|
@if (_tooLargeBytes > 0)
|
||||||
|
{
|
||||||
|
@* Non-blocking: the author keeps editing, but learns now (not at Save) that this text cannot be sent. *@
|
||||||
|
<div class="text-warning small mt-1" role="status">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="GetValueAsync"/> at save time.
|
||||||
|
/// </summary>
|
||||||
[Parameter] public string Value { get; set; } = "";
|
[Parameter] public string Value { get; set; } = "";
|
||||||
|
/// <summary>Raised with the editor text when the editor loses focus after an edit (and from <see cref="GetValueAsync"/>).</summary>
|
||||||
[Parameter] public EventCallback<string> ValueChanged { get; set; }
|
[Parameter] public EventCallback<string> ValueChanged { get; set; }
|
||||||
[Parameter] public string Language { get; set; } = "csharp";
|
[Parameter] public string Language { get; set; } = "csharp";
|
||||||
[Parameter] public string Height { get; set; } = "320px";
|
[Parameter] public string Height { get; set; } = "320px";
|
||||||
@@ -37,17 +52,20 @@
|
|||||||
[Parameter] public string? EquipmentId { get; set; }
|
[Parameter] public string? EquipmentId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
|
/// Fires whenever Monaco's diagnostic marker set updates (after the 500 ms debounce, and only for the response
|
||||||
/// debounce). The marker DTO is not modelled yet — typed as object[] until a
|
/// to the CURRENT text — superseded responses are dropped in the JS). Feeds <c>ScriptProblemsList</c>; the
|
||||||
/// later task wires the Roslyn-backed diagnostics.
|
/// markers are sent from JS only when this callback has a subscriber, and at most
|
||||||
|
/// <see cref="ScriptEditorLimits.MaxReportedMarkers"/> of them (plus the total) so the report always fits in one
|
||||||
|
/// circuit message.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public EventCallback<object[]> MarkersChanged { get; set; }
|
[Parameter] public EventCallback<ScriptProblemReport> MarkersChanged { get; set; }
|
||||||
|
|
||||||
private ElementReference _hostRef;
|
private ElementReference _hostRef;
|
||||||
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
|
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
|
||||||
private readonly string _id = Guid.NewGuid().ToString("N");
|
private readonly string _id = Guid.NewGuid().ToString("N");
|
||||||
private string _lastSentValue = "";
|
private string _lastSentValue = "";
|
||||||
private bool _initialized;
|
private bool _initialized;
|
||||||
|
private int _tooLargeBytes;
|
||||||
private bool _wrap;
|
private bool _wrap;
|
||||||
private bool _minimap;
|
private bool _minimap;
|
||||||
private bool _dark;
|
private bool _dark;
|
||||||
@@ -69,7 +87,11 @@
|
|||||||
value = Value ?? "",
|
value = Value ?? "",
|
||||||
language = Language,
|
language = Language,
|
||||||
readOnly = ReadOnly,
|
readOnly = ReadOnly,
|
||||||
equipmentId = EquipmentId
|
equipmentId = EquipmentId,
|
||||||
|
maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes,
|
||||||
|
reportMarkers = MarkersChanged.HasDelegate,
|
||||||
|
maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers,
|
||||||
|
maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars
|
||||||
},
|
},
|
||||||
_dotNetRef);
|
_dotNetRef);
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
@@ -126,9 +148,57 @@
|
|||||||
return ValueChanged.InvokeAsync(_lastSentValue);
|
return ValueChanged.InvokeAsync(_lastSentValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the editor's current text on demand (call it at Save), raises <see cref="ValueChanged"/> when it
|
||||||
|
/// differs from the last value reported, and returns it. Before the editor is live (prerender, failed init)
|
||||||
|
/// the bound <see cref="Value"/> is authoritative and is returned as-is.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The current text, or an error (e.g. the script is too large to send) — never a stale value.</returns>
|
||||||
|
public async Task<EditorReadResult> GetValueAsync()
|
||||||
|
{
|
||||||
|
if (!_initialized) return new EditorReadResult(Value ?? "", null);
|
||||||
|
string? current;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
current = await JS.InvokeAsync<string?>("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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The outcome of <see cref="GetValueAsync"/>: the text, or why it could not be read.</summary>
|
||||||
|
/// <param name="Value">The editor text; null when <paramref name="Error"/> is set.</param>
|
||||||
|
/// <param name="Error">Why the text could not be read; null on success.</param>
|
||||||
|
public sealed record EditorReadResult(string? Value, string? Error)
|
||||||
|
{
|
||||||
|
/// <summary>True when <see cref="Value"/> holds the editor text.</summary>
|
||||||
|
public bool Ok => Error is null;
|
||||||
|
}
|
||||||
|
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public Task OnMarkersChanged(object[] markers) =>
|
public Task OnMarkersChanged(DiagnosticMarker[]? markers, int total) =>
|
||||||
MarkersChanged.InvokeAsync(markers ?? Array.Empty<object>());
|
MarkersChanged.InvokeAsync(ScriptProblemReport.FromEditor(markers, total));
|
||||||
|
|
||||||
|
/// <summary>The JS reports, at blur, that the text has grown past (bytes > 0) or back under (0) the sync limit.</summary>
|
||||||
|
[JSInvokable]
|
||||||
|
public void OnSyncRefused(int bytes)
|
||||||
|
{
|
||||||
|
if (_tooLargeBytes == bytes) return;
|
||||||
|
_tooLargeBytes = bytes;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
|
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
|
||||||
public async Task RevealLineAsync(int line, int column = 1)
|
public async Task RevealLineAsync(int line, int column = 1)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
<div class="script-problems mt-2 small" role="region" aria-label="Problems">
|
||||||
|
<div class="d-flex align-items-center gap-2 text-muted mb-1">
|
||||||
|
<span class="fw-semibold">Problems</span>
|
||||||
|
<span>@ScriptProblems.Summary(report with { Markers = ordered })</span>
|
||||||
|
</div>
|
||||||
|
@if (ordered.Count > 0)
|
||||||
|
{
|
||||||
|
<ul class="list-group list-group-flush border rounded" style="max-height: 10rem; overflow-y: auto;">
|
||||||
|
@foreach (var m in ordered.Take(MaxRows))
|
||||||
|
{
|
||||||
|
<li class="list-group-item list-group-item-action py-1 px-2 d-flex gap-2 align-items-baseline"
|
||||||
|
role="button" tabindex="0" style="cursor: pointer;"
|
||||||
|
title="Go to line @m.StartLineNumber, column @m.StartColumn"
|
||||||
|
@onclick="() => OnSelect.InvokeAsync(m)"
|
||||||
|
@onkeydown="e => OnKeyAsync(e, m)">
|
||||||
|
<span class="badge @BadgeClass(m.Severity)">@ScriptProblems.SeverityLabel(m.Severity)</span>
|
||||||
|
<span class="mono text-muted">@m.StartLineNumber:@m.StartColumn</span>
|
||||||
|
<span class="flex-grow-1">@m.Message</span>
|
||||||
|
<span class="mono text-muted">@m.Code</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@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. *@
|
||||||
|
<div class="text-muted mt-1">+@(report.Total - Math.Min(ordered.Count, MaxRows)) more — fix the problems above, or scroll the editor to see them all.</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private const int MaxRows = ScriptEditorLimits.MaxReportedMarkers;
|
||||||
|
|
||||||
|
/// <summary>The report the editor last sent (null before the first diagnostics pass).</summary>
|
||||||
|
[Parameter] public ScriptProblemReport? Report { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised when the author picks a problem; wire it to <c>MonacoEditor.RevealLineAsync</c>.</summary>
|
||||||
|
[Parameter] public EventCallback<DiagnosticMarker> 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
@using System.ComponentModel.DataAnnotations
|
@using System.ComponentModel.DataAnnotations
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
|
||||||
@inject IUnsTreeService Svc
|
@inject IUnsTreeService Svc
|
||||||
|
|
||||||
@if (Visible)
|
@if (Visible)
|
||||||
@@ -99,7 +100,9 @@
|
|||||||
Editing shared script "<span class="mono">@_scriptName</span>" — used by
|
Editing shared script "<span class="mono">@_scriptName</span>" — used by
|
||||||
@_scriptUsageCount virtual tag(s). Changes affect all of them.
|
@_scriptUsageCount virtual tag(s). Changes affect all of them.
|
||||||
</div>
|
</div>
|
||||||
<MonacoEditor @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId" />
|
<MonacoEditor @ref="_scriptEditor" @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId"
|
||||||
|
MarkersChanged="OnScriptMarkersChanged" />
|
||||||
|
<ScriptProblemsList Report="_scriptMarkers" OnSelect="RevealScriptProblemAsync" />
|
||||||
@if (!string.IsNullOrWhiteSpace(_scriptError))
|
@if (!string.IsNullOrWhiteSpace(_scriptError))
|
||||||
{
|
{
|
||||||
<div class="text-danger small mt-2">@_scriptError</div>
|
<div class="text-danger small mt-2">@_scriptError</div>
|
||||||
@@ -217,6 +220,24 @@
|
|||||||
private bool _scriptSaving;
|
private bool _scriptSaving;
|
||||||
private bool _scriptSaved;
|
private bool _scriptSaved;
|
||||||
private string? _scriptError;
|
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<bool> 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).
|
// "New script" action state (shown only when no script is bound yet).
|
||||||
private bool _scriptCreating;
|
private bool _scriptCreating;
|
||||||
@@ -227,6 +248,9 @@
|
|||||||
if (!Visible)
|
if (!Visible)
|
||||||
{
|
{
|
||||||
_loadedKey = null; // closed → next open reloads fresh
|
_loadedKey = null; // closed → next open reloads fresh
|
||||||
|
// Closing removes the editor component: drop its reference and its problems.
|
||||||
|
_scriptEditor = null;
|
||||||
|
_scriptMarkers = ScriptProblemReport.Empty;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +302,9 @@
|
|||||||
{
|
{
|
||||||
_scriptSaved = false;
|
_scriptSaved = false;
|
||||||
_scriptError = null;
|
_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))
|
if (string.IsNullOrEmpty(_form.ScriptId))
|
||||||
{
|
{
|
||||||
@@ -358,8 +385,16 @@
|
|||||||
/// Toggles the inline script-source panel. Clears the stale "Script saved." banner when
|
/// 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.
|
/// (re)expanding so a collapse/expand cycle doesn't re-show a confirmation from an earlier save.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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;
|
_scriptExpanded = !_scriptExpanded;
|
||||||
if (_scriptExpanded)
|
if (_scriptExpanded)
|
||||||
{
|
{
|
||||||
@@ -384,6 +419,7 @@
|
|||||||
_scriptError = null;
|
_scriptError = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (!await PullScriptSourceAsync()) return;
|
||||||
var result = await Svc.UpdateScriptSourceAsync(_form.ScriptId, _scriptSource, _scriptRowVersion);
|
var result = await Svc.UpdateScriptSourceAsync(_form.ScriptId, _scriptSource, _scriptRowVersion);
|
||||||
if (result.Ok)
|
if (result.Ok)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
/// <returns>The same service collection, for chaining.</returns>
|
/// <returns>The same service collection, for chaining.</returns>
|
||||||
public static IServiceCollection AddAdminUI(this IServiceCollection services)
|
public static IServiceCollection AddAdminUI(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddRazorComponents().AddInteractiveServerComponents();
|
services.AddRazorComponents().AddInteractiveServerComponents().AddHubOptions(o => o.MaximumReceiveMessageSize = ScriptAnalysis.ScriptEditorLimits.HubMaximumReceiveMessageSize);
|
||||||
services.AddOtOpcUaDriverStatusServices();
|
services.AddOtOpcUaDriverStatusServices();
|
||||||
|
|
||||||
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
|
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
|
||||||
|
|||||||
@@ -105,31 +105,20 @@ public sealed class ScriptTagCatalog(
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
|
public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct);
|
// The sorted, de-duplicated path array is built once per snapshot; a keystroke is a binary search.
|
||||||
|
var index = await cache.GetOrBuildIndexAsync(BuildEntriesAsync, ct);
|
||||||
var paths = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
|
return index.FindByPrefix(filter, MaxResults);
|
||||||
|
|
||||||
IEnumerable<string> 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
public async Task<ScriptTagInfo?> 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
|
// 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
|
// 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".
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -22,7 +22,3 @@ public record SignatureHelpParameter(string Label, string? Documentation);
|
|||||||
|
|
||||||
public record FormatRequest(string Code);
|
public record FormatRequest(string Code);
|
||||||
public record FormatResponse(string Code);
|
public record FormatResponse(string Code);
|
||||||
|
|
||||||
public record InlayHintsRequest(string Code);
|
|
||||||
public record InlayHintsResponse(IReadOnlyList<InlayHint> Hints);
|
|
||||||
public record InlayHint(int Line, int Column, string Label);
|
|
||||||
|
|||||||
@@ -8,20 +8,37 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
|||||||
|
|
||||||
public static class ScriptAnalysisEndpoints
|
public static class ScriptAnalysisEndpoints
|
||||||
{
|
{
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
private const int ClientClosedRequest = 499;
|
||||||
|
|
||||||
/// <summary>Maps the <c>/api/script-analysis</c> endpoint group (diagnostics, completions, hover, signature help, format), gated by the ConfigEditor policy (Administrator or Designer).</summary>
|
/// <summary>Maps the <c>/api/script-analysis</c> endpoint group (diagnostics, completions, hover, signature help, format), gated by the ConfigEditor policy (Administrator or Designer).</summary>
|
||||||
/// <param name="endpoints">The route builder to map the endpoints onto.</param>
|
/// <param name="endpoints">The route builder to map the endpoints onto.</param>
|
||||||
/// <returns>The same route builder, for chaining.</returns>
|
/// <returns>The same route builder, for chaining.</returns>
|
||||||
|
/// <remarks>Every analysis handler binds the request's <see cref="CancellationToken"/> (<c>HttpContext.RequestAborted</c>)
|
||||||
|
/// and passes it to the analysis, so a request the editor abandoned stops compiling instead of running to completion.</remarks>
|
||||||
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
|
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
|
||||||
{
|
{
|
||||||
// ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
|
// ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
|
||||||
var group = endpoints.MapGroup("/api/script-analysis")
|
var group = endpoints.MapGroup("/api/script-analysis")
|
||||||
.RequireAuthorization(AdminUiPolicies.ConfigEditor);
|
.RequireAuthorization(AdminUiPolicies.ConfigEditor);
|
||||||
group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r)));
|
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.DiagnoseAsync(r, ct), ct));
|
||||||
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
|
group.MapPost("/completions", (CompletionsRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.CompleteAsync(r, ct), ct));
|
||||||
group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r)));
|
group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.Hover(r, ct), ct));
|
||||||
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
|
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("/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;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Run<T>(Func<Task<T>> analyse, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Results.Ok(await analyse());
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Results.StatusCode(ClientClosedRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
|||||||
/// <see cref="VirtualTagContext"/> context.
|
/// <see cref="VirtualTagContext"/> context.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format
|
/// <para>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
|
/// are wired and backed by the real Roslyn document/analysis pipeline.</para>
|
||||||
/// intentionally a no-op (empty list) — the endpoint exists for future use but the feature is
|
/// <para><b>Cost model.</b> Every analysis derives from one process-wide <see cref="BaseCompilation"/>
|
||||||
/// not implemented.
|
/// via <see cref="CSharpCompilation.ReplaceSyntaxTree"/>, so the sandbox reference set is bound once rather
|
||||||
|
/// than per keystroke, and every entry point takes the request's <see cref="CancellationToken"/>
|
||||||
|
/// (<c>HttpContext.RequestAborted</c>) so an abandoned request stops instead of running to completion.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ScriptAnalysisService
|
public sealed class ScriptAnalysisService
|
||||||
{
|
{
|
||||||
@@ -56,16 +58,39 @@ public sealed class ScriptAnalysisService
|
|||||||
+ " var ctx = globals.ctx;\n#line 1\n";
|
+ " var ctx = globals.ctx;\n#line 1\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// userSource is appended after Preamble, then the method/class are closed.
|
private static SyntaxTree WrapAndParse(string userSource, CancellationToken ct)
|
||||||
private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(string userSource)
|
=> CSharpSyntaxTree.ParseText(Preamble + (userSource ?? "") + "\n }\n}\n", cancellationToken: ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The process-wide compilation every analysis derives from. Its sandbox reference set is bound ONCE
|
||||||
|
/// (forced here by resolving <c>System.Object</c>); <see cref="CSharpCompilation.ReplaceSyntaxTree"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static readonly CSharpCompilation BaseCompilation = CreateBaseCompilation();
|
||||||
|
|
||||||
|
private static CSharpCompilation CreateBaseCompilation()
|
||||||
{
|
{
|
||||||
var prefix = Preamble;
|
var compilation = CSharpCompilation.Create(
|
||||||
var full = prefix + (userSource ?? "") + "\n }\n}\n";
|
"ScriptAnalysis", new[] { WrapAndParse("", CancellationToken.None) }, Sandbox.References, CompileOptions);
|
||||||
var tree = CSharpSyntaxTree.ParseText(full);
|
_ = compilation.GetSpecialType(SpecialType.System_Object);
|
||||||
var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions);
|
return compilation;
|
||||||
return (tree, compilation, compilation.GetSemanticModel(tree), prefix.Length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test seam: the compilation one analysis of <paramref name="userSource"/> runs against.</summary>
|
||||||
|
internal CSharpCompilation CompilationFor(string userSource) => Analyze(Normalize(userSource), CancellationToken.None).Compilation;
|
||||||
|
|
||||||
// editor (line,col) in USER-source coords -> physical offset in the wrapped document
|
// 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)
|
private static int OffsetInWrapped(string userSource, int line, int column, int preambleLength)
|
||||||
=> preambleLength + Math.Clamp(PositionToOffset(userSource ?? "", line, column), 0, (userSource ?? "").Length);
|
=> preambleLength + Math.Clamp(PositionToOffset(userSource ?? "", line, column), 0, (userSource ?? "").Length);
|
||||||
@@ -87,14 +112,16 @@ public sealed class ScriptAnalysisService
|
|||||||
/// <summary>Compiles the script and returns compile-error diagnostics plus sandbox-policy violations
|
/// <summary>Compiles the script and returns compile-error diagnostics plus sandbox-policy violations
|
||||||
/// (forbidden types, disallowed dynamic tag paths).</summary>
|
/// (forbidden types, disallowed dynamic tag paths).</summary>
|
||||||
/// <param name="req">The diagnose request carrying the script source.</param>
|
/// <param name="req">The diagnose request carrying the script source.</param>
|
||||||
|
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
||||||
/// <returns>The list of diagnostic markers, empty when the script is clean or analysis fails.</returns>
|
/// <returns>The list of diagnostic markers, empty when the script is clean or analysis fails.</returns>
|
||||||
public DiagnoseResponse Diagnose(DiagnoseRequest req)
|
public DiagnoseResponse Diagnose(DiagnoseRequest req, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
var code = Normalize(req.Code);
|
var code = Normalize(req.Code);
|
||||||
var (tree, compilation, _, preambleLength) = Analyze(code);
|
var (tree, compilation, _, preambleLength) = Analyze(code, ct);
|
||||||
var markers = new List<DiagnosticMarker>();
|
var markers = new List<DiagnosticMarker>();
|
||||||
|
|
||||||
// (1) Roslyn — surface ONLY compile errors, the same severity that actually blocks a
|
// (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
|
// makes GetMappedLineSpan report user-source coords (GetLineSpan would return the
|
||||||
// physical wrapper line and be wrong); the preambleLength guard also drops phantom
|
// 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.
|
// 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.Severity != DiagnosticSeverity.Error || !d.Location.IsInSource) continue;
|
||||||
if (d.Location.SourceSpan.Start < preambleLength) 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));
|
markers.Add(ToMarker(span, Sev(d.Severity), d.GetMessage(), d.Id));
|
||||||
}
|
}
|
||||||
// (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; map via #line.
|
// (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; map via #line.
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation))
|
foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation))
|
||||||
markers.Add(ToMarker(tree.GetMappedLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN"));
|
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.
|
// (3) DependencyExtractor — spans are offsets in the normalized user source; map directly.
|
||||||
foreach (var r in DependencyExtractor.Extract(code).Rejections)
|
foreach (var r in DependencyExtractor.Extract(code).Rejections)
|
||||||
markers.Add(ToUserMarker(code, r.Span, r.Message, "OTSCRIPT_DYNPATH"));
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -144,10 +181,11 @@ public sealed class ScriptAnalysisService
|
|||||||
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
|
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
|
||||||
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
|
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
|
||||||
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
|
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
|
||||||
|
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
||||||
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
|
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
|
||||||
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req)
|
public async Task<DiagnoseResponse> 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))
|
if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code))
|
||||||
return baseResp;
|
return baseResp;
|
||||||
try
|
try
|
||||||
@@ -156,7 +194,7 @@ public sealed class ScriptAnalysisService
|
|||||||
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
|
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
|
||||||
return baseResp;
|
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<string>(names, StringComparer.Ordinal);
|
var resolvable = new HashSet<string>(names, StringComparer.Ordinal);
|
||||||
var markers = new List<DiagnosticMarker>(baseResp.Markers);
|
var markers = new List<DiagnosticMarker>(baseResp.Markers);
|
||||||
|
|
||||||
@@ -173,7 +211,11 @@ public sealed class ScriptAnalysisService
|
|||||||
$"reference named '{refName}'. Add a matching reference or correct the path.",
|
$"reference named '{refName}'. Add a matching reference or correct the path.",
|
||||||
"OTSCRIPT_EQUIPREF"));
|
"OTSCRIPT_EQUIPREF"));
|
||||||
}
|
}
|
||||||
return new DiagnoseResponse(markers.Distinct().ToList());
|
return new DiagnoseResponse(Bound(markers));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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<DiagnosticMarker> Bound(IEnumerable<DiagnosticMarker> 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<InvocationExpressionSyntax> SetVirtualTagCalls(SyntaxNode root, int preambleLength)
|
||||||
|
=> root.DescendantNodes()
|
||||||
|
.OfType<InvocationExpressionSyntax>()
|
||||||
|
.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
|
private static int Sev(DiagnosticSeverity s) => s switch
|
||||||
{
|
{
|
||||||
DiagnosticSeverity.Error => 8,
|
DiagnosticSeverity.Error => 8,
|
||||||
@@ -210,16 +281,18 @@ public sealed class ScriptAnalysisService
|
|||||||
}
|
}
|
||||||
/// <summary>Computes completion items (tag-path, dot-member, or in-scope symbol completions) at the caret position.</summary>
|
/// <summary>Computes completion items (tag-path, dot-member, or in-scope symbol completions) at the caret position.</summary>
|
||||||
/// <param name="req">The completions request carrying the script source and caret position.</param>
|
/// <param name="req">The completions request carrying the script source and caret position.</param>
|
||||||
|
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
||||||
/// <returns>The resolved list of completion items, empty when none apply.</returns>
|
/// <returns>The resolved list of completion items, empty when none apply.</returns>
|
||||||
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
|
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
|
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
var code = Normalize(req.CodeText);
|
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 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.").
|
// 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));
|
var token = root.FindToken(Math.Max(0, position - 1));
|
||||||
|
|
||||||
@@ -233,26 +306,31 @@ public sealed class ScriptAnalysisService
|
|||||||
if (string.IsNullOrEmpty(req.EquipmentId))
|
if (string.IsNullOrEmpty(req.EquipmentId))
|
||||||
return new CompletionsResponse(Array.Empty<CompletionItem>());
|
return new CompletionsResponse(Array.Empty<CompletionItem>());
|
||||||
var names = await _catalog.GetEquipmentReferenceNamesAsync(
|
var names = await _catalog.GetEquipmentReferenceNamesAsync(
|
||||||
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None);
|
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), ct);
|
||||||
return new CompletionsResponse(names
|
return new CompletionsResponse(names
|
||||||
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
|
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
|
||||||
.ToList());
|
.ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
var paths = await _catalog.GetPathsAsync(pathPrefix, CancellationToken.None);
|
var paths = await _catalog.GetPathsAsync(pathPrefix, ct);
|
||||||
return new CompletionsResponse(
|
return new CompletionsResponse(
|
||||||
paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList());
|
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);
|
if (dot != null) return new CompletionsResponse(dot);
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
var scoped = model.LookupSymbols(position)
|
var scoped = model.LookupSymbols(position)
|
||||||
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
|
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
|
||||||
.GroupBy(s => s.Name).Select(g => g.First())
|
.GroupBy(s => s.Name).Select(g => g.First())
|
||||||
.Select(ToCompletionItem).Take(200).ToList();
|
.Select(ToCompletionItem).Take(200).ToList();
|
||||||
return new CompletionsResponse(scoped);
|
return new CompletionsResponse(scoped);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger?.LogWarning(ex, "Script completion failed; returning none.");
|
_logger?.LogWarning(ex, "Script completion failed; returning none.");
|
||||||
@@ -260,12 +338,12 @@ public sealed class ScriptAnalysisService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
|
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var memberAccess = token.Parent as MemberAccessExpressionSyntax
|
var memberAccess = token.Parent as MemberAccessExpressionSyntax
|
||||||
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
|
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
|
||||||
if (memberAccess == null) return null;
|
if (memberAccess == null) return null;
|
||||||
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
|
var typeInfo = model.GetTypeInfo(memberAccess.Expression, ct);
|
||||||
var type = typeInfo.Type ?? typeInfo.ConvertedType;
|
var type = typeInfo.Type ?? typeInfo.ConvertedType;
|
||||||
if (type == null) return null;
|
if (type == null) return null;
|
||||||
// The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose
|
// The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose
|
||||||
@@ -322,16 +400,18 @@ public sealed class ScriptAnalysisService
|
|||||||
}
|
}
|
||||||
/// <summary>Computes hover information (tag-path info or C# symbol info) for the position under the caret.</summary>
|
/// <summary>Computes hover information (tag-path info or C# symbol info) for the position under the caret.</summary>
|
||||||
/// <param name="req">The hover request carrying the script source and caret position.</param>
|
/// <param name="req">The hover request carrying the script source and caret position.</param>
|
||||||
|
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
||||||
/// <returns>The resolved hover markdown, or a response with a null value when nothing resolves.</returns>
|
/// <returns>The resolved hover markdown, or a response with a null value when nothing resolves.</returns>
|
||||||
public async Task<HoverResponse> Hover(HoverRequest req)
|
public async Task<HoverResponse> Hover(HoverRequest req, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(req.CodeText)) return new HoverResponse(null);
|
if (string.IsNullOrWhiteSpace(req.CodeText)) return new HoverResponse(null);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
var code = Normalize(req.CodeText);
|
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 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
|
// 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
|
// 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.");
|
"(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
|
var tagMd = info is null
|
||||||
? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path."
|
? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path."
|
||||||
: $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**"
|
: $"**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
|
// Resolve the token's node; if that yields nothing, climb to the enclosing
|
||||||
// member-access (e.g. hovering the `GetTag` identifier — the IdentifierName itself
|
// member-access (e.g. hovering the `GetTag` identifier — the IdentifierName itself
|
||||||
// carries no symbol info, but its parent member-access binds to the method).
|
// 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);
|
if (symbol is null) return new HoverResponse(null);
|
||||||
var display = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
|
var display = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
|
||||||
var summary = TryGetXmlSummary(symbol);
|
var summary = TryGetXmlSummary(symbol);
|
||||||
var md = "```csharp\n" + display + "\n```" + (summary is null ? "" : "\n\n" + summary);
|
var md = "```csharp\n" + display + "\n```" + (summary is null ? "" : "\n\n" + summary);
|
||||||
return new HoverResponse(md);
|
return new HoverResponse(md);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||||
catch (Exception ex) { _logger?.LogWarning(ex, "Script hover failed."); return new HoverResponse(null); }
|
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))
|
foreach (var candidate in ResolutionCandidates(node))
|
||||||
{
|
{
|
||||||
var info = model.GetSymbolInfo(candidate);
|
var info = model.GetSymbolInfo(candidate, ct);
|
||||||
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
|
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
|
||||||
if (symbol is not null) return symbol;
|
if (symbol is not null) return symbol;
|
||||||
}
|
}
|
||||||
@@ -420,24 +501,26 @@ public sealed class ScriptAnalysisService
|
|||||||
|
|
||||||
/// <summary>Computes signature help for the method invocation at the caret position.</summary>
|
/// <summary>Computes signature help for the method invocation at the caret position.</summary>
|
||||||
/// <param name="req">The signature-help request carrying the script source and caret position.</param>
|
/// <param name="req">The signature-help request carrying the script source and caret position.</param>
|
||||||
|
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
||||||
/// <returns>The resolved signature and active-parameter index, or an empty response when none applies.</returns>
|
/// <returns>The resolved signature and active-parameter index, or an empty response when none applies.</returns>
|
||||||
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req)
|
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var empty = new SignatureHelpResponse(null, null, 0);
|
var empty = new SignatureHelpResponse(null, null, 0);
|
||||||
if (string.IsNullOrWhiteSpace(req.CodeText)) return empty;
|
if (string.IsNullOrWhiteSpace(req.CodeText)) return empty;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
var code = Normalize(req.CodeText);
|
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 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;
|
InvocationExpressionSyntax? inv = null;
|
||||||
for (var n = token.Parent; n is not null; n = n.Parent)
|
for (var n = token.Parent; n is not null; n = n.Parent)
|
||||||
if (n is InvocationExpressionSyntax found) { inv = found; break; }
|
if (n is InvocationExpressionSyntax found) { inv = found; break; }
|
||||||
if (inv is null) return empty;
|
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;
|
var method = (info.Symbol ?? info.CandidateSymbols.FirstOrDefault()) as IMethodSymbol;
|
||||||
if (method is null) return empty;
|
if (method is null) return empty;
|
||||||
|
|
||||||
@@ -453,6 +536,7 @@ public sealed class ScriptAnalysisService
|
|||||||
|
|
||||||
return new SignatureHelpResponse(label, ps, active);
|
return new SignatureHelpResponse(label, ps, active);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||||
catch (Exception ex) { _logger?.LogWarning(ex, "Script signature help failed."); return empty; }
|
catch (Exception ex) { _logger?.LogWarning(ex, "Script signature help failed."); return empty; }
|
||||||
}
|
}
|
||||||
/// <summary>Formats a script's source code using Roslyn's syntax-tree whitespace normalization.</summary>
|
/// <summary>Formats a script's source code using Roslyn's syntax-tree whitespace normalization.</summary>
|
||||||
@@ -474,9 +558,4 @@ public sealed class ScriptAnalysisService
|
|||||||
return new FormatResponse(req.Code);
|
return new FormatResponse(req.Code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// <summary>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.</summary>
|
|
||||||
/// <param name="req">The inlay-hints request (unused).</param>
|
|
||||||
/// <returns>An always-empty inlay-hints response.</returns>
|
|
||||||
public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty<InlayHint>());
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>Size limits for moving Monaco editor text across the Blazor Server circuit.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <c>MaximumReceiveMessageSize</c>. The framework
|
||||||
|
/// default is 32 KB, which silently lost any larger script (the oversize invoke failed and Save persisted the
|
||||||
|
/// stale source), so <c>AddAdminUI</c> raises it to <see cref="HubMaximumReceiveMessageSize"/>. The editor JS
|
||||||
|
/// refuses to send a value whose serialized form exceeds <see cref="MaxEditorSyncBytes"/> — an oversize frame
|
||||||
|
/// would tear the circuit down — and the save surfaces that refusal to the author instead.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ScriptEditorLimits
|
||||||
|
{
|
||||||
|
/// <summary>The Blazor hub's receive limit, in bytes (1 MB).</summary>
|
||||||
|
public const long HubMaximumReceiveMessageSize = 1024 * 1024;
|
||||||
|
|
||||||
|
/// <summary>The largest serialized editor value the JS will send, leaving 64 KB of frame headroom.</summary>
|
||||||
|
public const int MaxEditorSyncBytes = (int)HubMaximumReceiveMessageSize - 64 * 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="MaxDiagnosticMarkers"/>).
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxReportedMarkers = 200;
|
||||||
|
|
||||||
|
/// <summary>Each reported marker's message is truncated to this many characters before it crosses the circuit.</summary>
|
||||||
|
public const int MaxReportedMessageChars = 300;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxDiagnosticMarkers = 1000;
|
||||||
|
|
||||||
|
/// <summary>Each diagnostics-endpoint marker message is truncated to this many characters (a Roslyn message
|
||||||
|
/// can quote an arbitrarily long pasted token).</summary>
|
||||||
|
public const int MaxDiagnosticMessageChars = 1000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Presentation rules for the script editor's Problems list — the markers Monaco reports through
|
||||||
|
/// <c>MonacoEditor.MarkersChanged</c> (the same <see cref="DiagnosticMarker"/> shape the diagnostics endpoint
|
||||||
|
/// returns), rendered by <c>ScriptProblemsList</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class ScriptProblems
|
||||||
|
{
|
||||||
|
/// <summary>Monaco's error severity.</summary>
|
||||||
|
public const int Error = 8;
|
||||||
|
|
||||||
|
/// <summary>Monaco's warning severity.</summary>
|
||||||
|
public const int Warning = 4;
|
||||||
|
|
||||||
|
/// <summary>Errors first, then warnings, then the rest; within a severity, by position.</summary>
|
||||||
|
/// <param name="markers">The reported markers (null treated as none).</param>
|
||||||
|
/// <returns>The markers in display order.</returns>
|
||||||
|
public static IReadOnlyList<DiagnosticMarker> Order(IEnumerable<DiagnosticMarker>? markers)
|
||||||
|
=> (markers ?? Array.Empty<DiagnosticMarker>())
|
||||||
|
.OrderByDescending(m => m.Severity)
|
||||||
|
.ThenBy(m => m.StartLineNumber)
|
||||||
|
.ThenBy(m => m.StartColumn)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>The label for a Monaco severity value.</summary>
|
||||||
|
/// <param name="severity">8 = error, 4 = warning, 2 = info, otherwise hint.</param>
|
||||||
|
/// <returns>The display label.</returns>
|
||||||
|
public static string SeverityLabel(int severity) => severity switch
|
||||||
|
{
|
||||||
|
Error => "Error",
|
||||||
|
Warning => "Warning",
|
||||||
|
2 => "Info",
|
||||||
|
_ => "Hint",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A one-line count, e.g. <c>"2 errors, 1 warning"</c>.</summary>
|
||||||
|
/// <param name="markers">The reported markers.</param>
|
||||||
|
/// <returns>The summary, or <c>"No problems"</c>.</returns>
|
||||||
|
public static string Summary(IReadOnlyCollection<DiagnosticMarker> 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<string>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A one-line count for a (possibly capped) report, e.g. <c>"200 errors (showing 200 of 4321)"</c>.</summary>
|
||||||
|
/// <param name="report">The report the editor sent.</param>
|
||||||
|
/// <returns>The summary of the shown markers, with the shown/total suffix when some were left out.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cuts <paramref name="text"/> to at most <paramref name="maxChars"/> characters, ending in an ellipsis.</summary>
|
||||||
|
/// <param name="text">The text.</param>
|
||||||
|
/// <param name="maxChars">The maximum length, ellipsis included (at least 1).</param>
|
||||||
|
/// <returns>The text, unchanged when it already fits.</returns>
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the editor reported for the Problems list: at most <see cref="ScriptEditorLimits.MaxReportedMarkers"/>
|
||||||
|
/// markers in display order (errors first) plus the <see cref="Total"/> 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
|
||||||
|
/// <see cref="FromEditor"/> clamps it again on arrival.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Markers">The markers shown.</param>
|
||||||
|
/// <param name="Total">How many markers the diagnostics pass produced (≥ <c>Markers.Count</c>).</param>
|
||||||
|
public sealed record ScriptProblemReport(IReadOnlyList<DiagnosticMarker> Markers, int Total)
|
||||||
|
{
|
||||||
|
/// <summary>No problems.</summary>
|
||||||
|
public static readonly ScriptProblemReport Empty = new(Array.Empty<DiagnosticMarker>(), 0);
|
||||||
|
|
||||||
|
/// <summary>How many markers were left out of <see cref="Markers"/>.</summary>
|
||||||
|
public int Omitted => Math.Max(0, Total - Markers.Count);
|
||||||
|
|
||||||
|
/// <summary>Builds a report from what the editor JS sent, re-applying the circuit caps defensively.</summary>
|
||||||
|
/// <param name="markers">The markers sent (already capped by the JS).</param>
|
||||||
|
/// <param name="total">The total the JS counted.</param>
|
||||||
|
/// <returns>The clamped report.</returns>
|
||||||
|
public static ScriptProblemReport FromEditor(IReadOnlyList<DiagnosticMarker>? markers, int total)
|
||||||
|
{
|
||||||
|
if (markers is null || markers.Count == 0) return total <= 0 ? Empty : new(Array.Empty<DiagnosticMarker>(), 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The query-ready form of one <see cref="ScriptTagCatalogSnapshotCache"/> snapshot: the raw entries, a sorted
|
||||||
|
/// de-duplicated path array for prefix completion, and an Ordinal path→info dictionary for hover.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Built ONCE per snapshot (at most every 5 s) instead of per keystroke. Completion used to build a
|
||||||
|
/// <see cref="HashSet{T}"/> 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 <c>StartsWith</c>, sorted <see cref="StringComparer.OrdinalIgnoreCase"/>,
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ScriptTagCatalogIndex
|
||||||
|
{
|
||||||
|
private static readonly StringComparer SortComparer = StringComparer.OrdinalIgnoreCase;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, ScriptTagInfo> _byPath;
|
||||||
|
private readonly string[] _sortedPaths;
|
||||||
|
|
||||||
|
private ScriptTagCatalogIndex(IReadOnlyList<ScriptTagInfo> entries, string[] sortedPaths, Dictionary<string, ScriptTagInfo> byPath)
|
||||||
|
{
|
||||||
|
Entries = entries;
|
||||||
|
_sortedPaths = sortedPaths;
|
||||||
|
_byPath = byPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The projection the index was built from.</summary>
|
||||||
|
public IReadOnlyList<ScriptTagInfo> Entries { get; }
|
||||||
|
|
||||||
|
/// <summary>Distinct (case-insensitively) paths, sorted <see cref="StringComparer.OrdinalIgnoreCase"/>.</summary>
|
||||||
|
public IReadOnlyList<string> SortedPaths => _sortedPaths;
|
||||||
|
|
||||||
|
/// <summary>Builds the index over a catalog projection.</summary>
|
||||||
|
/// <param name="entries">The catalog entries, in projection order (first occurrence wins on duplicates).</param>
|
||||||
|
/// <returns>The built index.</returns>
|
||||||
|
public static ScriptTagCatalogIndex Build(IReadOnlyList<ScriptTagInfo> 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<string>(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<string, ScriptTagInfo>(entries.Count, StringComparer.Ordinal);
|
||||||
|
foreach (var e in entries) byPath.TryAdd(e.Path, e);
|
||||||
|
|
||||||
|
return new ScriptTagCatalogIndex(entries, sorted.ToArray(), byPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Paths starting (case-insensitively) with <paramref name="prefix"/>, in sorted order.</summary>
|
||||||
|
/// <param name="prefix">The literal prefix; null/whitespace returns the first <paramref name="max"/> paths.</param>
|
||||||
|
/// <param name="max">Upper bound on the returned count.</param>
|
||||||
|
/// <returns>At most <paramref name="max"/> matching paths.</returns>
|
||||||
|
public IReadOnlyList<string> 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<string>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exact (Ordinal) lookup of a path.</summary>
|
||||||
|
/// <param name="path">The path to look up.</param>
|
||||||
|
/// <returns>The first entry for that path, or <see langword="null"/>.</returns>
|
||||||
|
public ScriptTagInfo? TryGet(string path) => _byPath.GetValueOrDefault(path);
|
||||||
|
}
|
||||||
+19
-6
@@ -55,24 +55,37 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider)
|
|||||||
public async Task<IReadOnlyList<ScriptTagInfo>> GetOrBuildAsync(
|
public async Task<IReadOnlyList<ScriptTagInfo>> GetOrBuildAsync(
|
||||||
Func<CancellationToken, Task<IReadOnlyList<ScriptTagInfo>>> build,
|
Func<CancellationToken, Task<IReadOnlyList<ScriptTagInfo>>> build,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
|
=> (await GetOrBuildIndexAsync(build, ct)).Entries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// As <see cref="GetOrBuildAsync"/>, but returns the snapshot's query-ready
|
||||||
|
/// <see cref="ScriptTagCatalogIndex"/> (sorted paths + path dictionary), which is built once per rebuild
|
||||||
|
/// rather than once per completion keystroke.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="build">Builds a fresh projection; invoked at most once per TTL window.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>The index over a fresh snapshot, or over one just built.</returns>
|
||||||
|
public async Task<ScriptTagCatalogIndex> GetOrBuildIndexAsync(
|
||||||
|
Func<CancellationToken, Task<IReadOnlyList<ScriptTagInfo>>> build,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var current = _snapshot;
|
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);
|
await _rebuildGate.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Double-check: while we queued, the caller ahead of us may have rebuilt already.
|
// Double-check: while we queued, the caller ahead of us may have rebuilt already.
|
||||||
current = _snapshot;
|
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
|
// 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.
|
// 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.
|
// Stamped AFTER the build so a slow scan does not hand back an already-expired snapshot.
|
||||||
_snapshot = new Snapshot(entries, timeProvider.GetUtcNow());
|
_snapshot = new Snapshot(index, timeProvider.GetUtcNow());
|
||||||
return entries;
|
return index;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -82,5 +95,5 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider)
|
|||||||
|
|
||||||
private bool IsStale(Snapshot snapshot) => timeProvider.GetUtcNow() - snapshot.TakenAt >= Ttl;
|
private bool IsStale(Snapshot snapshot) => timeProvider.GetUtcNow() - snapshot.TakenAt >= Ttl;
|
||||||
|
|
||||||
private sealed record Snapshot(IReadOnlyList<ScriptTagInfo> Entries, DateTimeOffset TakenAt);
|
private sealed record Snapshot(ScriptTagCatalogIndex Index, DateTimeOffset TakenAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,30 @@
|
|||||||
|
|
||||||
const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 };
|
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
|
// 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}}/<RefName>
|
// registered csharp providers (which only receive the model) can thread it into the {{equip}}/<RefName>
|
||||||
// completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there).
|
// completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there).
|
||||||
@@ -31,15 +55,12 @@
|
|||||||
function registerCSharpProviders() {
|
function registerCSharpProviders() {
|
||||||
monaco.languages.registerCompletionItemProvider("csharp", {
|
monaco.languages.registerCompletionItemProvider("csharp", {
|
||||||
triggerCharacters: [".", "(", "\""],
|
triggerCharacters: [".", "(", "\""],
|
||||||
provideCompletionItems: async function (model, position) {
|
provideCompletionItems: async function (model, position, context, token) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/script-analysis/completions", {
|
const data = await postAnalysis("completions",
|
||||||
method: "POST", credentials: "same-origin",
|
{ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) },
|
||||||
headers: { "Content-Type": "application/json" },
|
token);
|
||||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
|
if (!data) return { suggestions: [] };
|
||||||
});
|
|
||||||
if (!resp.ok) return { suggestions: [] };
|
|
||||||
const data = await resp.json();
|
|
||||||
const word = model.getWordUntilPosition(position);
|
const word = model.getWordUntilPosition(position);
|
||||||
const wordRange = {
|
const wordRange = {
|
||||||
startLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
|
startLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
|
||||||
@@ -72,67 +93,34 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
monaco.languages.registerHoverProvider("csharp", {
|
monaco.languages.registerHoverProvider("csharp", {
|
||||||
provideHover: async function (model, position) {
|
provideHover: async function (model, position, token) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/script-analysis/hover", {
|
const data = await postAnalysis("hover",
|
||||||
method: "POST", credentials: "same-origin",
|
{ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) },
|
||||||
headers: { "Content-Type": "application/json" },
|
token);
|
||||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
|
if (!data || !data.markdown) return null;
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.markdown) return null;
|
|
||||||
return { contents: [{ value: data.markdown }] };
|
return { contents: [{ value: data.markdown }] };
|
||||||
} catch (e) { return null; }
|
} catch (e) { return null; }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
monaco.languages.registerDocumentFormattingEditProvider("csharp", {
|
monaco.languages.registerDocumentFormattingEditProvider("csharp", {
|
||||||
provideDocumentFormattingEdits: async function (model) {
|
provideDocumentFormattingEdits: async function (model, options, token) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/script-analysis/format", {
|
const data = await postAnalysis("format", { code: model.getValue() }, token);
|
||||||
method: "POST", credentials: "same-origin",
|
if (!data || typeof data.code !== "string" || data.code === model.getValue()) return [];
|
||||||
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 [];
|
|
||||||
return [{ range: model.getFullModelRange(), text: data.code }];
|
return [{ range: model.getFullModelRange(), text: data.code }];
|
||||||
} catch (e) { return []; }
|
} 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", {
|
monaco.languages.registerSignatureHelpProvider("csharp", {
|
||||||
signatureHelpTriggerCharacters: ["(", ","],
|
signatureHelpTriggerCharacters: ["(", ","],
|
||||||
signatureHelpRetriggerCharacters: [","],
|
signatureHelpRetriggerCharacters: [","],
|
||||||
provideSignatureHelp: async function (model, position) {
|
provideSignatureHelp: async function (model, position, token) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/script-analysis/signature-help", {
|
const data = await postAnalysis("signature-help",
|
||||||
method: "POST", credentials: "same-origin",
|
{ codeText: model.getValue(), line: position.lineNumber, column: position.column },
|
||||||
headers: { "Content-Type": "application/json" },
|
token);
|
||||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column })
|
if (!data || !data.label) return null;
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.label) return null;
|
|
||||||
return {
|
return {
|
||||||
value: {
|
value: {
|
||||||
signatures: [{
|
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 {
|
try {
|
||||||
const resp = await fetch("/api/script-analysis/diagnostics", {
|
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" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) })
|
body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) })
|
||||||
});
|
});
|
||||||
if (!resp.ok) return [];
|
if (!resp.ok) return [];
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
return data.markers || [];
|
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).
|
// 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
|
// Stamp the owning-equipment id on the model so the global csharp providers can thread it into the
|
||||||
// {{equip}}/<RefName> completion + diagnostics (null on the shared ScriptEdit page).
|
// {{equip}}/<RefName> completion + diagnostics (null on the shared ScriptEdit page).
|
||||||
try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {}
|
try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {}
|
||||||
let diagTimer = null;
|
const e = {
|
||||||
const scheduleDiagnostics = function () {
|
editor: editor, dotNetRef: dotNetRef, dirty: false,
|
||||||
if (diagTimer) clearTimeout(diagTimer);
|
maxSyncBytes: options.maxSyncBytes || 0, reportMarkers: !!options.reportMarkers,
|
||||||
diagTimer = setTimeout(async function () {
|
maxReportedMarkers: options.maxReportedMarkers || 200, maxReportedMessageChars: options.maxReportedMessageChars || 300,
|
||||||
const model = editor.getModel(); if (!model) return;
|
tooLargeNotified: false,
|
||||||
const markers = await fetchDiagnostics(model);
|
diagTimer: null, diagGeneration: 0, diagAbort: null
|
||||||
monaco.editor.setModelMarkers(model, "otopcua", markers);
|
};
|
||||||
dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {});
|
// Diagnostics: the 500 ms debounce delays the START of a request; the generation counter + AbortController
|
||||||
}, 500);
|
// 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 () {
|
editor.onDidChangeModelContent(function () {
|
||||||
dotNetRef.invokeMethodAsync("OnValueChanged", editor.getValue()).catch(function () {});
|
e.dirty = true;
|
||||||
if (options.language === "csharp") scheduleDiagnostics();
|
if (options.language === "csharp") e.scheduleDiagnostics();
|
||||||
});
|
});
|
||||||
editors[id] = { editor: editor, dotNetRef: dotNetRef };
|
editor.onDidBlurEditorText(function () { syncValue(e); });
|
||||||
if (options.language === "csharp") scheduleDiagnostics();
|
editors[id] = e;
|
||||||
|
if (options.language === "csharp") e.scheduleDiagnostics();
|
||||||
}
|
}
|
||||||
function setEditorOption(id, name, value) {
|
function setEditorOption(id, name, value) {
|
||||||
const e = editors[id]; if (!e) return;
|
const e = editors[id]; if (!e) return;
|
||||||
@@ -228,10 +294,28 @@
|
|||||||
const e = editors[id]; if (!e) return;
|
const e = editors[id]; if (!e) return;
|
||||||
e.editor.revealLineInCenter(line); e.editor.setPosition({ lineNumber: line, column: col || 1 }); e.editor.focus();
|
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 setValue(id, v) {
|
||||||
function getValue(id) { const e = editors[id]; return e ? e.editor.getValue() : null; }
|
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 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 };
|
window.MonacoBlazor = { createEditor, setValue, getValue, setMarkers, setEditorOption, format, revealLine, dispose };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R04:P2 — an abandoned editor request (Monaco cancelled it, or the browser aborted the fetch) used to run
|
||||||
|
/// its full compile to completion. The request's token now reaches the service, the Roslyn calls and the
|
||||||
|
/// catalog, and the endpoints bind <c>HttpContext.RequestAborted</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AnalysisCancellationTests
|
||||||
|
{
|
||||||
|
internal sealed class CapturingCatalog : IScriptTagCatalog
|
||||||
|
{
|
||||||
|
public readonly List<CancellationToken> Seen = new();
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Seen.Add(ct);
|
||||||
|
return Task.FromResult<IReadOnlyList<string>>(new[] { "Plant/Dev/Speed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Seen.Add(ct);
|
||||||
|
return Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Tag", "Float", "DRV-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Seen.Add(ct);
|
||||||
|
return Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CancellationToken Cancelled() => new(canceled: true);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Diagnose_with_a_cancelled_token_throws_instead_of_returning_empty_markers()
|
||||||
|
{
|
||||||
|
var svc = new ScriptAnalysisService();
|
||||||
|
// Positive control: the same request with a live token produces the error marker.
|
||||||
|
(await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), CancellationToken.None))
|
||||||
|
.Markers.ShouldContain(m => m.Severity == 8);
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(
|
||||||
|
svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), Cancelled()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Completions_hover_and_signature_help_honour_a_cancelled_token()
|
||||||
|
{
|
||||||
|
var svc = new ScriptAnalysisService(new CapturingCatalog());
|
||||||
|
const string code = "return ctx.GetTag(\"\");";
|
||||||
|
|
||||||
|
// Positive controls.
|
||||||
|
(await svc.CompleteAsync(new CompletionsRequest(code, 1, 20), CancellationToken.None)).Items.ShouldNotBeEmpty();
|
||||||
|
(await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), CancellationToken.None))
|
||||||
|
.Markdown.ShouldNotBeNull();
|
||||||
|
svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), CancellationToken.None).Label.ShouldNotBeNull();
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(svc.CompleteAsync(new CompletionsRequest(code, 1, 20), Cancelled()));
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(
|
||||||
|
svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), Cancelled()));
|
||||||
|
Should.Throw<OperationCanceledException>(() => svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), Cancelled()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_request_token_reaches_the_catalog()
|
||||||
|
{
|
||||||
|
var catalog = new CapturingCatalog();
|
||||||
|
var svc = new ScriptAnalysisService(catalog);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
await svc.CompleteAsync(new CompletionsRequest("return ctx.GetTag(\"\");", 1, 20), cts.Token);
|
||||||
|
await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), cts.Token);
|
||||||
|
await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.GetTag(\"{{equip}}/Nope\").Value;", "EQ-1"), cts.Token);
|
||||||
|
|
||||||
|
catalog.Seen.Count.ShouldBe(3);
|
||||||
|
catalog.Seen.ShouldAllBe(t => t == cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- endpoints
|
||||||
|
|
||||||
|
// A bare DefaultHttpContext reports "no body"; Kestrel sets this feature for a real POST.
|
||||||
|
private sealed class HasBody : Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature
|
||||||
|
{
|
||||||
|
public bool CanHaveBody => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static (WebApplication App, CapturingCatalog Catalog) BuildApp()
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateBuilder();
|
||||||
|
var catalog = new CapturingCatalog();
|
||||||
|
builder.Services.AddSingleton<IScriptTagCatalog>(catalog);
|
||||||
|
builder.Services.AddScoped(sp => new ScriptAnalysisService(sp.GetRequiredService<IScriptTagCatalog>()));
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
var app = builder.Build();
|
||||||
|
app.MapScriptAnalysisEndpoints();
|
||||||
|
return (app, catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<RouteEndpoint> Endpoints(WebApplication app)
|
||||||
|
=> ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType<RouteEndpoint>().ToList();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_endpoint_binds_RequestAborted_through_to_the_catalog()
|
||||||
|
{
|
||||||
|
var (app, catalog) = BuildApp();
|
||||||
|
var endpoint = Endpoints(app).Single(e => e.RoutePattern.RawText!.EndsWith("/completions"));
|
||||||
|
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
using var aborted = new CancellationTokenSource();
|
||||||
|
var ctx = new DefaultHttpContext { RequestServices = scope.ServiceProvider, RequestAborted = aborted.Token };
|
||||||
|
ctx.Request.Method = "POST";
|
||||||
|
ctx.Request.ContentType = "application/json";
|
||||||
|
var body = Encoding.UTF8.GetBytes("""{"codeText":"return ctx.GetTag(\"\");","line":1,"column":20}""");
|
||||||
|
ctx.Request.Body = new MemoryStream(body);
|
||||||
|
ctx.Request.ContentLength = body.Length;
|
||||||
|
ctx.Response.Body = new MemoryStream();
|
||||||
|
ctx.Features.Set<Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature>(new HasBody());
|
||||||
|
|
||||||
|
await endpoint.RequestDelegate!(ctx);
|
||||||
|
|
||||||
|
ctx.Response.StatusCode.ShouldBe(200);
|
||||||
|
catalog.Seen.ShouldHaveSingleItem().ShouldBe(aborted.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R04:P2 — the editor used to build a brand-new <c>CSharpCompilation</c> (and re-bind the whole sandbox
|
||||||
|
/// reference set) on every completion / hover / signature-help / diagnostics request. Each analysis now
|
||||||
|
/// derives from ONE process-wide base compilation via <c>ReplaceSyntaxTree</c>, so the referenced-assembly
|
||||||
|
/// binding is done once and shared.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AnalysisCompilationReuseTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Two_services_share_the_bound_reference_set()
|
||||||
|
{
|
||||||
|
// Two separate scopes (two HTTP requests) analysing different sources.
|
||||||
|
var c1 = new ScriptAnalysisService().CompilationFor("return 1;");
|
||||||
|
var c2 = new ScriptAnalysisService().CompilationFor("return 2;");
|
||||||
|
|
||||||
|
// Positive control: the two compilations really carry different user sources.
|
||||||
|
c1.SyntaxTrees.Single().ToString().ShouldContain("return 1;");
|
||||||
|
c2.SyntaxTrees.Single().ToString().ShouldContain("return 2;");
|
||||||
|
c1.ShouldNotBeSameAs(c2);
|
||||||
|
|
||||||
|
// Derived from one bound base: the corlib symbol is the SAME instance, not a re-bound copy.
|
||||||
|
c1.GetSpecialType(SpecialType.System_Object)
|
||||||
|
.ShouldBeSameAs(c2.GetSpecialType(SpecialType.System_Object));
|
||||||
|
c1.GetSpecialType(SpecialType.System_Object)
|
||||||
|
.ShouldBeSameAs(ScriptAnalysisService.BaseCompilation.GetSpecialType(SpecialType.System_Object));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Repeated_analysis_in_one_scope_replaces_the_tree_rather_than_adding_one()
|
||||||
|
{
|
||||||
|
var svc = new ScriptAnalysisService();
|
||||||
|
svc.CompilationFor("return 1;");
|
||||||
|
var second = svc.CompilationFor("return 2;");
|
||||||
|
|
||||||
|
second.SyntaxTrees.Count().ShouldBe(1);
|
||||||
|
second.SyntaxTrees.Single().ToString().ShouldContain("return 2;");
|
||||||
|
second.SyntaxTrees.Single().ToString().ShouldNotContain("return 1;");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Diagnostics_are_unchanged_by_reuse()
|
||||||
|
{
|
||||||
|
// A clean script stays clean and an error stays an error across interleaved analyses on one service
|
||||||
|
// — a stale tree leaking from the previous call would surface here.
|
||||||
|
var svc = new ScriptAnalysisService();
|
||||||
|
svc.Diagnose(new DiagnoseRequest("return ctx.Nope();")).Markers.ShouldContain(m => m.Severity == 8);
|
||||||
|
svc.Diagnose(new DiagnoseRequest("""return (double)ctx.GetTag("A").Value * 2.0;""")).Markers.ShouldBeEmpty();
|
||||||
|
svc.Diagnose(new DiagnoseRequest("var a = 1;\nreturn ctx.Nope();")).Markers
|
||||||
|
.ShouldContain(m => m.Severity == 8 && m.StartLineNumber == 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review D-a I-1 — the diagnostics markers cross the Blazor circuit (JS → .NET, for the Problems list) as ONE
|
||||||
|
/// hub message. A non-C# paste yields several Roslyn errors per line; uncapped, a few thousand markers exceed
|
||||||
|
/// the 1 MB hub limit and kill the circuit with every unsaved edit. Both the endpoint response and the circuit
|
||||||
|
/// report are now bounded; these tests pin the bounds on the C# side of the contract.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MarkerPayloadBoundTests
|
||||||
|
{
|
||||||
|
// ~150 KB of log-ish text: every line is several C# errors.
|
||||||
|
private static string NonCSharpPaste()
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (var i = 0; sb.Length < 150 * 1024; i++)
|
||||||
|
sb.Append($"2026-10-22 12:00:{i % 60:00} INFO [worker-{i}] value={i}, state=ok; next -> {i + 1} @@ ##\n");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_non_csharp_paste_returns_a_bounded_marker_set_that_still_holds_the_errors()
|
||||||
|
{
|
||||||
|
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest(NonCSharpPaste())).Markers;
|
||||||
|
|
||||||
|
markers.Count.ShouldBe(ScriptEditorLimits.MaxDiagnosticMarkers); // positive control: the cap was hit
|
||||||
|
markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars);
|
||||||
|
markers[0].Severity.ShouldBe(8); // errors first — publish rejects, so must the editor
|
||||||
|
markers.Select(m => (m.Severity, m.StartLineNumber, m.StartColumn))
|
||||||
|
.ShouldBe(ScriptProblems.Order(markers).Select(m => (m.Severity, m.StartLineNumber, m.StartColumn)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_long_quoted_token_has_its_message_truncated()
|
||||||
|
{
|
||||||
|
var giant = new string('x', 20_000);
|
||||||
|
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest($"return {giant};")).Markers;
|
||||||
|
|
||||||
|
markers.ShouldNotBeEmpty();
|
||||||
|
markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars);
|
||||||
|
markers.ShouldContain(m => m.Message.EndsWith("…"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_small_script_is_not_truncated()
|
||||||
|
{
|
||||||
|
// Positive control for the cap: ordinary diagnostics pass through untouched.
|
||||||
|
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest("return ctx.Nope();")).Markers;
|
||||||
|
markers.ShouldContain(m => m.Severity == 8 && !m.Message.EndsWith("…"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_worst_case_circuit_report_fits_inside_the_sync_budget()
|
||||||
|
{
|
||||||
|
// The JS sends at most MaxReportedMarkers markers, each message cut to MaxReportedMessageChars. Worst case:
|
||||||
|
// control characters (\u0001 → 6 JSON bytes, 7 when escaped again) and the largest line/column numbers.
|
||||||
|
var message = new string('\u0001', ScriptEditorLimits.MaxReportedMessageChars);
|
||||||
|
var marker = new DiagnosticMarker(8, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, message, "OTSCRIPT_SETVTAG_INERT");
|
||||||
|
var report = Enumerable.Repeat(marker, ScriptEditorLimits.MaxReportedMarkers).ToArray();
|
||||||
|
|
||||||
|
var once = JsonSerializer.Serialize(new object[] { report, int.MaxValue });
|
||||||
|
var twice = Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(once));
|
||||||
|
|
||||||
|
twice.ShouldBeLessThan(ScriptEditorLimits.MaxEditorSyncBytes);
|
||||||
|
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_truncated_report_says_how_many_were_left_out()
|
||||||
|
{
|
||||||
|
var shown = Enumerable.Range(1, 3).Select(i => new DiagnosticMarker(8, i, 1, i, 2, "m", "CS1002")).ToArray();
|
||||||
|
|
||||||
|
var report = new ScriptProblemReport(shown, 4321);
|
||||||
|
report.Omitted.ShouldBe(4318);
|
||||||
|
ScriptProblems.Summary(report).ShouldBe("3 errors (showing 3 of 4321)");
|
||||||
|
|
||||||
|
// Positive control: an untruncated report carries no suffix.
|
||||||
|
ScriptProblems.Summary(new ScriptProblemReport(shown, 3)).ShouldBe("3 errors");
|
||||||
|
ScriptProblemReport.Empty.Omitted.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_report_from_js_is_clamped_even_if_the_js_cap_regressed()
|
||||||
|
{
|
||||||
|
var flood = Enumerable.Range(1, 5000).Select(i => new DiagnosticMarker(4, i, 1, i, 2, new string('m', 2000), "W")).ToArray();
|
||||||
|
|
||||||
|
var report = ScriptProblemReport.FromEditor(flood, total: 5000);
|
||||||
|
|
||||||
|
report.Markers.Count.ShouldBe(ScriptEditorLimits.MaxReportedMarkers);
|
||||||
|
report.Total.ShouldBe(5000);
|
||||||
|
report.Markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxReportedMessageChars);
|
||||||
|
ScriptProblemReport.FromEditor(null, 0).ShouldBe(ScriptProblemReport.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source pins for the Monaco interop (AdminUI has no bUnit and no JS test runner). These do not prove the
|
||||||
|
/// browser behaviour — the docker-dev live check does — they stop the specific regressions R04:P1/P9/S2/S3 fixed
|
||||||
|
/// from quietly coming back: per-keystroke text sync, unsequenced diagnostics, the inlay-hints POST, and an
|
||||||
|
/// unconsumed marker round-trip.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class MonacoInteropSourceTests
|
||||||
|
{
|
||||||
|
private static string Read(params string[] relative)
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||||
|
dir = dir.Parent;
|
||||||
|
dir.ShouldNotBeNull("could not locate the repository root");
|
||||||
|
return File.ReadAllText(Path.Combine(new[] { dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI" }.Concat(relative).ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Js() => Read("wwwroot", "js", "monaco-init.js");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_content_change_handler_does_not_ship_the_text_to_dotnet()
|
||||||
|
{
|
||||||
|
var js = Js();
|
||||||
|
var handler = Regex.Match(js, @"onDidChangeModelContent\(function \(\) \{(?<body>.*?)\n \}\);", RegexOptions.Singleline);
|
||||||
|
handler.Success.ShouldBeTrue("the content-change handler moved; update this pin");
|
||||||
|
handler.Groups["body"].Value.ShouldNotContain("OnValueChanged");
|
||||||
|
handler.Groups["body"].Value.ShouldNotContain("getValue");
|
||||||
|
// Positive control: the text IS still reported — at blur.
|
||||||
|
js.ShouldContain("onDidBlurEditorText");
|
||||||
|
js.ShouldContain("\"OnValueChanged\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Diagnostics_are_generation_guarded_and_abortable()
|
||||||
|
{
|
||||||
|
var js = Js();
|
||||||
|
js.ShouldContain("diagGeneration");
|
||||||
|
js.ShouldContain("generation !== e.diagGeneration");
|
||||||
|
js.ShouldContain("new AbortController()");
|
||||||
|
js.ShouldContain("signal: signal");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Providers_honour_the_monaco_cancellation_token()
|
||||||
|
{
|
||||||
|
var js = Js();
|
||||||
|
js.ShouldContain("token.onCancellationRequested");
|
||||||
|
foreach (var path in new[] { "\"completions\"", "\"hover\"", "\"format\"", "\"signature-help\"" })
|
||||||
|
js.ShouldContain("postAnalysis(" + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_inlay_hints_provider_is_not_registered() => Js().ShouldNotContain("registerInlayHintsProvider");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Markers_cross_to_dotnet_only_for_a_subscribed_problems_list()
|
||||||
|
{
|
||||||
|
Js().ShouldContain("if (e.reportMarkers) {");
|
||||||
|
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("reportMarkers = MarkersChanged.HasDelegate");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_markers_report_is_capped_before_it_crosses_the_circuit()
|
||||||
|
{
|
||||||
|
// Review I-1: the only OnMarkersChanged invoke sends the capped report, never the raw marker array.
|
||||||
|
var js = Js();
|
||||||
|
Regex.Matches(js, "\"OnMarkersChanged\"").Count.ShouldBe(1);
|
||||||
|
js.ShouldContain("dotNetRef.invokeMethodAsync(\"OnMarkersChanged\", report.markers, report.total)");
|
||||||
|
js.ShouldContain("const report = markerReport(e, markers);");
|
||||||
|
js.ShouldContain(".slice(0, max)");
|
||||||
|
js.ShouldContain("m.message.substring(0, maxChars - 1)");
|
||||||
|
// The caps come from the C# constants, so the JS and ScriptProblemReport.FromEditor agree.
|
||||||
|
var razor = Read("Components", "Shared", "MonacoEditor.razor");
|
||||||
|
razor.ShouldContain("maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers");
|
||||||
|
razor.ShouldContain("maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars");
|
||||||
|
razor.ShouldContain("ScriptProblemReport.FromEditor(markers, total)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_oversize_text_is_announced_at_blur_not_only_at_save()
|
||||||
|
{
|
||||||
|
Js().ShouldContain("invokeMethodAsync(\"OnSyncRefused\", serializedBytes(v))");
|
||||||
|
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("public void OnSyncRefused(int bytes)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Components", "Pages", "ScriptEdit.razor")]
|
||||||
|
[InlineData("Components", "Shared", "Uns", "VirtualTagModal.razor")]
|
||||||
|
public void Script_hosts_read_the_editor_on_save_and_render_the_problems_list(params string[] path)
|
||||||
|
{
|
||||||
|
var src = Read(path);
|
||||||
|
src.ShouldContain("GetValueAsync()");
|
||||||
|
src.ShouldContain("<ScriptProblemsList");
|
||||||
|
src.ShouldContain("RevealLineAsync(");
|
||||||
|
src.ShouldContain("MarkersChanged=");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R04:P1 / S2 — the Monaco editor no longer ships its text over the circuit per keystroke; the page reads it on
|
||||||
|
/// demand at blur / Save. That single read is one hub message, so the Blazor hub's receive limit is raised from
|
||||||
|
/// the 32 KB default to 1 MB, and the JS refuses (rather than sends) a value that would not fit.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MonacoSyncTests
|
||||||
|
{
|
||||||
|
private static HubOptions ComponentHubOptions(IServiceProvider sp)
|
||||||
|
{
|
||||||
|
// ComponentHub is internal to Microsoft.AspNetCore.Components.Server; resolve its options by type name.
|
||||||
|
var hubType = Type.GetType("Microsoft.AspNetCore.Components.Server.ComponentHub, Microsoft.AspNetCore.Components.Server", throwOnError: true)!;
|
||||||
|
var optionsType = typeof(IOptions<>).MakeGenericType(typeof(HubOptions<>).MakeGenericType(hubType));
|
||||||
|
var options = sp.GetRequiredService(optionsType);
|
||||||
|
return (HubOptions)optionsType.GetProperty("Value")!.GetValue(options)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_blazor_hub_accepts_a_1_MB_message()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddAdminUI();
|
||||||
|
using var sp = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
ComponentHubOptions(sp).MaximumReceiveMessageSize.ShouldBe(1024 * 1024);
|
||||||
|
ScriptEditorLimits.HubMaximumReceiveMessageSize.ShouldBe(1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_AddAdminUI_the_default_limit_is_the_32_KB_that_truncated_large_scripts()
|
||||||
|
{
|
||||||
|
// Positive control for the test above: the 1 MB comes from AddAdminUI, not from a framework default.
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddRazorComponents().AddInteractiveServerComponents();
|
||||||
|
using var sp = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
(ComponentHubOptions(sp).MaximumReceiveMessageSize ?? 32 * 1024).ShouldBe(32 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_editor_sync_budget_leaves_headroom_inside_the_hub_limit_and_fits_a_100_KB_script()
|
||||||
|
{
|
||||||
|
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize);
|
||||||
|
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeGreaterThan(4 * 100 * 1024); // 100 KB even after escaping twice
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R04:S3 / S2 — the marker round-trip now feeds a real Problems list (MarkersChanged → list → RevealLineAsync),
|
||||||
|
/// and the dead inlay-hints endpoint is gone. The Razor wiring is live-verified; the ordering and wording it
|
||||||
|
/// renders are pinned here.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProblemsListTests
|
||||||
|
{
|
||||||
|
private static DiagnosticMarker M(int sev, int line, int col, string code = "X") => new(sev, line, col, line, col + 1, "msg", code);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Problems_are_ordered_errors_first_then_by_position()
|
||||||
|
{
|
||||||
|
var ordered = ScriptProblems.Order(new[]
|
||||||
|
{
|
||||||
|
M(4, 1, 1, "W1"), M(8, 9, 2, "E3"), M(8, 2, 5, "E2"), M(8, 2, 1, "E1"), M(2, 1, 1, "I1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
ordered.Select(m => m.Code).ShouldBe(new[] { "E1", "E2", "E3", "W1", "I1" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_or_empty_markers_order_to_an_empty_list() => ScriptProblems.Order(null).ShouldBeEmpty();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(8, "Error")]
|
||||||
|
[InlineData(4, "Warning")]
|
||||||
|
[InlineData(2, "Info")]
|
||||||
|
[InlineData(1, "Hint")]
|
||||||
|
public void Severity_labels_follow_monaco_values(int severity, string label)
|
||||||
|
=> ScriptProblems.SeverityLabel(severity).ShouldBe(label);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Summary_counts_errors_and_warnings()
|
||||||
|
{
|
||||||
|
ScriptProblems.Summary(Array.Empty<DiagnosticMarker>()).ShouldBe("No problems");
|
||||||
|
ScriptProblems.Summary(new[] { M(8, 1, 1) }).ShouldBe("1 error");
|
||||||
|
ScriptProblems.Summary(new[] { M(8, 1, 1), M(8, 2, 1), M(4, 3, 1) }).ShouldBe("2 errors, 1 warning");
|
||||||
|
ScriptProblems.Summary(new[] { M(4, 1, 1), M(4, 2, 1), M(2, 3, 1) }).ShouldBe("2 warnings, 1 other");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_inlay_hints_endpoint_is_gone()
|
||||||
|
{
|
||||||
|
// The always-empty inlay-hints endpoint (and the JS provider that POSTed the full source to it on every
|
||||||
|
// edit) were removed. Positive control: the other five routes are still mapped.
|
||||||
|
var (app, _) = AnalysisCancellationTests.BuildApp();
|
||||||
|
var routes = AnalysisCancellationTests.Endpoints(app).Select(e => e.RoutePattern.RawText!).ToList();
|
||||||
|
|
||||||
|
routes.ShouldNotContain(r => r.Contains("inlay", StringComparison.OrdinalIgnoreCase));
|
||||||
|
foreach (var r in new[] { "/diagnostics", "/completions", "/hover", "/signature-help", "/format" })
|
||||||
|
routes.ShouldContain(x => x.EndsWith(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R04:P3 — the catalog used to build a HashSet of every fleet path and sort it on every completion keystroke,
|
||||||
|
/// and hover did a linear scan. The snapshot now carries a sorted, de-duplicated <c>string[]</c> (prefix-searched
|
||||||
|
/// with a binary search) and an Ordinal path→info dictionary, built once per snapshot.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScriptTagCatalogIndexTests
|
||||||
|
{
|
||||||
|
private const int Max = 200;
|
||||||
|
|
||||||
|
// The previous per-keystroke implementation, kept here as the oracle the index must match exactly.
|
||||||
|
private static IReadOnlyList<string> Naive(IReadOnlyList<ScriptTagInfo> entries, string? filter)
|
||||||
|
{
|
||||||
|
IEnumerable<string> q = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter))
|
||||||
|
q = q.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return q.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).Take(Max).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScriptTagInfo Tag(string path) => new(path, "Tag", "Float", "DRV-1");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Prefix_search_is_case_insensitive_sorted_and_bounded()
|
||||||
|
{
|
||||||
|
var index = ScriptTagCatalogIndex.Build(new[]
|
||||||
|
{
|
||||||
|
Tag("Plant/b/Speed"), Tag("plant/A/Temp"), Tag("Other/X"), Tag("Plant/a/Flow"),
|
||||||
|
});
|
||||||
|
|
||||||
|
index.FindByPrefix("PLANT/", Max).ShouldBe(new[] { "Plant/a/Flow", "plant/A/Temp", "Plant/b/Speed" });
|
||||||
|
index.FindByPrefix("other", Max).ShouldBe(new[] { "Other/X" });
|
||||||
|
index.FindByPrefix("zzz", Max).ShouldBeEmpty();
|
||||||
|
index.FindByPrefix(null, 2).Count.ShouldBe(2);
|
||||||
|
index.FindByPrefix(" ", Max).Count.ShouldBe(4); // whitespace filter = unfiltered, as before
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Case_only_duplicates_collapse_to_the_first_seen_casing()
|
||||||
|
{
|
||||||
|
// HashSet(OrdinalIgnoreCase) kept the FIRST-added casing; the index must too.
|
||||||
|
var index = ScriptTagCatalogIndex.Build(new[] { Tag("A/speed"), Tag("A/Speed"), Tag("A/SPEED") });
|
||||||
|
index.SortedPaths.ShouldBe(new[] { "A/speed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Exact_lookup_is_ordinal_and_keeps_the_first_entry()
|
||||||
|
{
|
||||||
|
var first = new ScriptTagInfo("A/B", "Tag", "Float", "DRV-1");
|
||||||
|
var index = ScriptTagCatalogIndex.Build(new[] { first, new ScriptTagInfo("A/B", "Virtual tag", "Int32", null) });
|
||||||
|
|
||||||
|
index.TryGet("A/B").ShouldBeSameAs(first);
|
||||||
|
index.TryGet("a/b").ShouldBeNull(); // case-mismatched path does not resolve at runtime
|
||||||
|
index.TryGet("A/C").ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Matches_the_previous_implementation_on_a_generated_fleet()
|
||||||
|
{
|
||||||
|
// Deterministic seed — a mixed-case fleet with case-only duplicates and shared prefixes.
|
||||||
|
var rng = new Random(745);
|
||||||
|
string Seg() => new string(Enumerable.Range(0, rng.Next(1, 4)).Select(_ => "abAB_/1"[rng.Next(7)]).ToArray());
|
||||||
|
var entries = Enumerable.Range(0, 3000).Select(_ => Tag(Seg() + "/" + Seg() + "/" + Seg())).ToList();
|
||||||
|
var index = ScriptTagCatalogIndex.Build(entries);
|
||||||
|
|
||||||
|
var filters = new List<string?> { null, "", "a", "A", "ab", "aB/", "b/", "_", "1", "/", "zz" };
|
||||||
|
filters.AddRange(entries.Take(50).Select(e => e.Path[..Math.Min(e.Path.Length, rng.Next(1, 6))]));
|
||||||
|
|
||||||
|
foreach (var f in filters)
|
||||||
|
index.FindByPrefix(f, Max).ShouldBe(Naive(entries, f), $"filter '{f}'");
|
||||||
|
// Positive control: the generated fleet really exercises truncation and filtering.
|
||||||
|
Naive(entries, null).Count.ShouldBe(Max);
|
||||||
|
Naive(entries, "ab").Count.ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R04:S1 — <c>ctx.SetVirtualTag(...)</c> compiles and publishes but is dropped at runtime (single-tag mode).
|
||||||
|
/// Only a hover used to say so; <c>Diagnose</c> now emits an <c>OTSCRIPT_SETVTAG_INERT</c> WARNING marker so the
|
||||||
|
/// Problems list shows it. A warning, never an error: publish accepts the script, so the editor must too.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SetVirtualTagInertDiagnosticTests
|
||||||
|
{
|
||||||
|
private const string InertCode = "OTSCRIPT_SETVTAG_INERT";
|
||||||
|
private static readonly ScriptAnalysisService Svc = new();
|
||||||
|
private static IReadOnlyList<DiagnosticMarker> Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetVirtualTag_call_gets_a_warning_marker_on_its_line()
|
||||||
|
{
|
||||||
|
var markers = Diag("var a = 1;\nvar b = 2;\nctx.SetVirtualTag(\"Other\", a + b);\nreturn 0;");
|
||||||
|
|
||||||
|
var m = markers.ShouldHaveSingleItem();
|
||||||
|
m.Code.ShouldBe(InertCode);
|
||||||
|
m.Severity.ShouldBe(4); // Monaco warning
|
||||||
|
m.StartLineNumber.ShouldBe(3);
|
||||||
|
m.StartColumn.ShouldBe(1);
|
||||||
|
m.Message.ShouldContain("SetVirtualTag");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_SetVirtualTag_call_is_marked()
|
||||||
|
=> Diag("ctx.SetVirtualTag(\"A\", 1);\nctx.SetVirtualTag(\"B\", 2);\nreturn 0;")
|
||||||
|
.Count(m => m.Code == InertCode).ShouldBe(2);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("return ctx.GetTag(\"A\").Value;")] // a different ctx member
|
||||||
|
[InlineData("return 0; // ctx.SetVirtualTag(\"A\", 1)")] // a comment
|
||||||
|
[InlineData("var s = \"ctx.SetVirtualTag(\\\"A\\\", 1)\"; return s;")] // a string literal
|
||||||
|
public void Non_calls_are_not_marked(string code)
|
||||||
|
{
|
||||||
|
var markers = Diag(code);
|
||||||
|
markers.ShouldNotContain(m => m.Code == InertCode);
|
||||||
|
markers.ShouldNotContain(m => m.Severity == 8); // the probe scripts themselves are valid
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_warning_does_not_block_and_publish_accepts_the_script()
|
||||||
|
{
|
||||||
|
// Editor accepts ⇔ publish accepts: no error-severity marker, and the real publish compiler accepts it.
|
||||||
|
const string source = "ctx.SetVirtualTag(\"Other\", 1);\nreturn 0;";
|
||||||
|
Diag(source).ShouldNotContain(m => m.Severity == 8);
|
||||||
|
using var evaluator = ScriptEvaluator<VirtualTagContext, object>.Compile(source);
|
||||||
|
evaluator.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user