fix(adminui): script analysis reuses one compilation, honours cancellation, indexes the catalog (D2)
R04:P2 - every analysis derives from one process-wide bound base compilation via ReplaceSyntaxTree (the scoped service keeps its current compilation); every entry point takes the request token, the endpoints bind RequestAborted and answer 499 on abort, and the Monaco providers abort their fetch when Monaco cancels the request. R04:P3 - the catalog snapshot carries a sorted, de-duplicated string[] (binary-search prefix completion) and an Ordinal path->info dictionary, built once per snapshot. R04:S1 - Diagnose emits an OTSCRIPT_SETVTAG_INERT warning for each ctx.SetVirtualTag call. It is a warning, so editor-accepts == publish-accepts still holds. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:P2 — an abandoned editor request (Monaco cancelled it, or the browser aborted the fetch) used to run
|
||||
/// its full compile to completion. The request's token now reaches the service, the Roslyn calls and the
|
||||
/// catalog, and the endpoints bind <c>HttpContext.RequestAborted</c>.
|
||||
/// </summary>
|
||||
public sealed class AnalysisCancellationTests
|
||||
{
|
||||
internal sealed class CapturingCatalog : IScriptTagCatalog
|
||||
{
|
||||
public readonly List<CancellationToken> Seen = new();
|
||||
|
||||
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
|
||||
{
|
||||
Seen.Add(ct);
|
||||
return Task.FromResult<IReadOnlyList<string>>(new[] { "Plant/Dev/Speed" });
|
||||
}
|
||||
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
{
|
||||
Seen.Add(ct);
|
||||
return Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Tag", "Float", "DRV-1"));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
{
|
||||
Seen.Add(ct);
|
||||
return Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
|
||||
}
|
||||
}
|
||||
|
||||
private static CancellationToken Cancelled() => new(canceled: true);
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnose_with_a_cancelled_token_throws_instead_of_returning_empty_markers()
|
||||
{
|
||||
var svc = new ScriptAnalysisService();
|
||||
// Positive control: the same request with a live token produces the error marker.
|
||||
(await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), CancellationToken.None))
|
||||
.Markers.ShouldContain(m => m.Severity == 8);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), Cancelled()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completions_hover_and_signature_help_honour_a_cancelled_token()
|
||||
{
|
||||
var svc = new ScriptAnalysisService(new CapturingCatalog());
|
||||
const string code = "return ctx.GetTag(\"\");";
|
||||
|
||||
// Positive controls.
|
||||
(await svc.CompleteAsync(new CompletionsRequest(code, 1, 20), CancellationToken.None)).Items.ShouldNotBeEmpty();
|
||||
(await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), CancellationToken.None))
|
||||
.Markdown.ShouldNotBeNull();
|
||||
svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), CancellationToken.None).Label.ShouldNotBeNull();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(svc.CompleteAsync(new CompletionsRequest(code, 1, 20), Cancelled()));
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), Cancelled()));
|
||||
Should.Throw<OperationCanceledException>(() => svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), Cancelled()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_request_token_reaches_the_catalog()
|
||||
{
|
||||
var catalog = new CapturingCatalog();
|
||||
var svc = new ScriptAnalysisService(catalog);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await svc.CompleteAsync(new CompletionsRequest("return ctx.GetTag(\"\");", 1, 20), cts.Token);
|
||||
await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), cts.Token);
|
||||
await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.GetTag(\"{{equip}}/Nope\").Value;", "EQ-1"), cts.Token);
|
||||
|
||||
catalog.Seen.Count.ShouldBe(3);
|
||||
catalog.Seen.ShouldAllBe(t => t == cts.Token);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- endpoints
|
||||
|
||||
// A bare DefaultHttpContext reports "no body"; Kestrel sets this feature for a real POST.
|
||||
private sealed class HasBody : Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature
|
||||
{
|
||||
public bool CanHaveBody => true;
|
||||
}
|
||||
|
||||
internal static (WebApplication App, CapturingCatalog Catalog) BuildApp()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
var catalog = new CapturingCatalog();
|
||||
builder.Services.AddSingleton<IScriptTagCatalog>(catalog);
|
||||
builder.Services.AddScoped(sp => new ScriptAnalysisService(sp.GetRequiredService<IScriptTagCatalog>()));
|
||||
builder.Services.AddAuthorization();
|
||||
var app = builder.Build();
|
||||
app.MapScriptAnalysisEndpoints();
|
||||
return (app, catalog);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<RouteEndpoint> Endpoints(WebApplication app)
|
||||
=> ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType<RouteEndpoint>().ToList();
|
||||
|
||||
[Fact]
|
||||
public async Task The_endpoint_binds_RequestAborted_through_to_the_catalog()
|
||||
{
|
||||
var (app, catalog) = BuildApp();
|
||||
var endpoint = Endpoints(app).Single(e => e.RoutePattern.RawText!.EndsWith("/completions"));
|
||||
|
||||
using var scope = app.Services.CreateScope();
|
||||
using var aborted = new CancellationTokenSource();
|
||||
var ctx = new DefaultHttpContext { RequestServices = scope.ServiceProvider, RequestAborted = aborted.Token };
|
||||
ctx.Request.Method = "POST";
|
||||
ctx.Request.ContentType = "application/json";
|
||||
var body = Encoding.UTF8.GetBytes("""{"codeText":"return ctx.GetTag(\"\");","line":1,"column":20}""");
|
||||
ctx.Request.Body = new MemoryStream(body);
|
||||
ctx.Request.ContentLength = body.Length;
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
ctx.Features.Set<Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature>(new HasBody());
|
||||
|
||||
await endpoint.RequestDelegate!(ctx);
|
||||
|
||||
ctx.Response.StatusCode.ShouldBe(200);
|
||||
catalog.Seen.ShouldHaveSingleItem().ShouldBe(aborted.Token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user