using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
///
/// R04:P2 — an abandoned editor request (Monaco cancelled it, or the browser aborted the fetch) used to run
/// its full compile to completion. The request's token now reaches the service, the Roslyn calls and the
/// catalog, and the endpoints bind HttpContext.RequestAborted.
///
public sealed class AnalysisCancellationTests
{
internal sealed class CapturingCatalog : IScriptTagCatalog
{
public readonly List Seen = new();
public Task> GetPathsAsync(string? filter, CancellationToken ct)
{
Seen.Add(ct);
return Task.FromResult>(new[] { "Plant/Dev/Speed" });
}
public Task GetTagInfoAsync(string path, CancellationToken ct)
{
Seen.Add(ct);
return Task.FromResult(new ScriptTagInfo(path, "Tag", "Float", "DRV-1"));
}
public Task> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
{
Seen.Add(ct);
return Task.FromResult>(new[] { "Speed" });
}
}
private static CancellationToken Cancelled() => new(canceled: true);
[Fact]
public async Task Diagnose_with_a_cancelled_token_throws_instead_of_returning_empty_markers()
{
var svc = new ScriptAnalysisService();
// Positive control: the same request with a live token produces the error marker.
(await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), CancellationToken.None))
.Markers.ShouldContain(m => m.Severity == 8);
await Should.ThrowAsync(
svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), Cancelled()));
}
[Fact]
public async Task Completions_hover_and_signature_help_honour_a_cancelled_token()
{
var svc = new ScriptAnalysisService(new CapturingCatalog());
const string code = "return ctx.GetTag(\"\");";
// Positive controls.
(await svc.CompleteAsync(new CompletionsRequest(code, 1, 20), CancellationToken.None)).Items.ShouldNotBeEmpty();
(await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), CancellationToken.None))
.Markdown.ShouldNotBeNull();
svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), CancellationToken.None).Label.ShouldNotBeNull();
await Should.ThrowAsync(svc.CompleteAsync(new CompletionsRequest(code, 1, 20), Cancelled()));
await Should.ThrowAsync(
svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), Cancelled()));
Should.Throw(() => svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), Cancelled()));
}
[Fact]
public async Task The_request_token_reaches_the_catalog()
{
var catalog = new CapturingCatalog();
var svc = new ScriptAnalysisService(catalog);
using var cts = new CancellationTokenSource();
await svc.CompleteAsync(new CompletionsRequest("return ctx.GetTag(\"\");", 1, 20), cts.Token);
await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), cts.Token);
await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.GetTag(\"{{equip}}/Nope\").Value;", "EQ-1"), cts.Token);
catalog.Seen.Count.ShouldBe(3);
catalog.Seen.ShouldAllBe(t => t == cts.Token);
}
// ---------------------------------------------------------------- endpoints
// A bare DefaultHttpContext reports "no body"; Kestrel sets this feature for a real POST.
private sealed class HasBody : Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature
{
public bool CanHaveBody => true;
}
internal static (WebApplication App, CapturingCatalog Catalog) BuildApp()
{
var builder = WebApplication.CreateBuilder();
var catalog = new CapturingCatalog();
builder.Services.AddSingleton(catalog);
builder.Services.AddScoped(sp => new ScriptAnalysisService(sp.GetRequiredService()));
builder.Services.AddAuthorization();
var app = builder.Build();
app.MapScriptAnalysisEndpoints();
return (app, catalog);
}
internal static IReadOnlyList Endpoints(WebApplication app)
=> ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType().ToList();
[Fact]
public async Task The_endpoint_binds_RequestAborted_through_to_the_catalog()
{
var (app, catalog) = BuildApp();
var endpoint = Endpoints(app).Single(e => e.RoutePattern.RawText!.EndsWith("/completions"));
using var scope = app.Services.CreateScope();
using var aborted = new CancellationTokenSource();
var ctx = new DefaultHttpContext { RequestServices = scope.ServiceProvider, RequestAborted = aborted.Token };
ctx.Request.Method = "POST";
ctx.Request.ContentType = "application/json";
var body = Encoding.UTF8.GetBytes("""{"codeText":"return ctx.GetTag(\"\");","line":1,"column":20}""");
ctx.Request.Body = new MemoryStream(body);
ctx.Request.ContentLength = body.Length;
ctx.Response.Body = new MemoryStream();
ctx.Features.Set(new HasBody());
await endpoint.RequestDelegate!(ctx);
ctx.Response.StatusCode.ShouldBe(200);
catalog.Seen.ShouldHaveSingleItem().ShouldBe(aborted.Token);
}
}