diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 4cbada93..58fb5b69 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -140,6 +140,7 @@ public static class ServiceCollectionExtensions // name collision, no instance binding, no native-alarm-source name reference); // the move dialog only SUBMITS the command and renders the returned outcome. services.AddScoped(); + services.AddScoped(); // Schema Library: authoring + read accessors for the reusable named // JSON-Schema library (SharedSchema + ISharedSchemaRepository). diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs new file mode 100644 index 00000000..b5de5d72 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs @@ -0,0 +1,301 @@ +using System.Security.Claims; +using System.Text.Json; +using Akka.Actor; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.ManagementService; +using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Default : prediction runs in-process over the repositories +/// (method script + instance → template chain → script bodies, DB-only, nothing executes); +/// the run/replay commands go to the per-node ManagementActor through +/// , exactly as +/// does, with the current Blazor principal projected to an +/// so the server's Deployer gate runs against the real identity. The Live-run Ask waits up +/// to (the server bounds a run by the method timeout + grace and +/// a value-change by its wait window; both fit inside two minutes). +/// +public sealed class FlowDebugService : IFlowDebugService +{ + /// Ask timeout for the two Live-run commands. + public static readonly TimeSpan RunAskTimeout = TimeSpan.FromMinutes(2); + + /// Ask timeout for Replay (a central DB read). + public static readonly TimeSpan ReplayAskTimeout = TimeSpan.FromSeconds(30); + + /// Upper bound on the template parent chain the script resolver walks (cycle guard). + private const int MaxTemplateChain = 16; + + private static readonly JsonSerializerOptions ResultJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly ManagementActorHolder _holder; + private readonly AuthenticationStateProvider _auth; + private readonly IInboundApiRepository _inboundApi; + private readonly ITemplateEngineRepository _templates; + private readonly ILogger _logger; + + /// Initializes the service. + public FlowDebugService( + ManagementActorHolder holder, + AuthenticationStateProvider auth, + IInboundApiRepository inboundApi, + ITemplateEngineRepository templates, + ILogger logger) + { + _holder = holder ?? throw new ArgumentNullException(nameof(holder)); + _auth = auth ?? throw new ArgumentNullException(nameof(auth)); + _inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi)); + _templates = templates ?? throw new ArgumentNullException(nameof(templates)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task PredictMethodAsync(int apiMethodId, CancellationToken cancellationToken = default) + { + var method = await _inboundApi.GetApiMethodByIdAsync(apiMethodId, cancellationToken); + if (method == null) + return PredictedFlow.Empty; + + // The predictor is synchronous and takes a synchronous resolver, so the instance → + // template → script lookups are pre-fetched lazily through a small memo that blocks + // on the repository — acceptable for a handful of constant route targets, and the + // predictor never asks for anything else. + var memo = new Dictionary<(string, string), string?>(); + string? Resolve(string instanceCode, string scriptName) + { + var key = (instanceCode, scriptName); + if (memo.TryGetValue(key, out var cached)) + return cached; + string? body; + try + { + body = ResolveSiteScriptAsync(instanceCode, scriptName, cancellationToken).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Flow prediction could not resolve {Instance}.{Script}", instanceCode, scriptName); + body = null; + } + + memo[key] = body; + return body; + } + + return FlowShapePredictor.PredictInboundMethod(method.Name, method.Script ?? string.Empty, Resolve); + } + + /// + public async Task PredictValueChangeAsync(int instanceId, string attributeName, CancellationToken cancellationToken = default) + { + var instance = await _templates.GetInstanceByIdAsync(instanceId, cancellationToken); + if (instance == null) + return PredictedFlow.Empty; + + var scripts = await CollectEffectiveScriptsAsync(instance.TemplateId, cancellationToken); + var triggered = scripts.Values + .Where(s => IsValueChangeOn(s, attributeName)) + .OrderBy(s => s.Name, StringComparer.Ordinal) + .ToList(); + + // Root: the write itself (Tags lane), then each triggered script predicted as a site script. + var nodes = new List + { + new("n1", FlowStepKind.AttributeWrite, FlowLane.Tags, $"{instance.UniqueName}.{attributeName}", false, + triggered.Count == 0 ? "no value-change script on this attribute" : null), + }; + var edges = new List(); + foreach (var script in triggered) + { + var sub = FlowShapePredictor.PredictSiteScript($"{instance.UniqueName}.{script.Name}", script.Code ?? string.Empty); + var offset = nodes.Count; + var map = new Dictionary(StringComparer.Ordinal); + foreach (var n in sub.Nodes) + { + var id = "n" + (nodes.Count + 1); + map[n.Id] = id; + nodes.Add(n with { Id = id }); + } + + if (sub.Nodes.Count > 0) + edges.Add(new PredictedEdge("n1", nodes[offset].Id, false)); + foreach (var e in sub.Edges) + edges.Add(e with { From = map[e.From], To = map[e.To] }); + } + + return new PredictedFlow(nodes, edges); + } + + /// + public Task RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default) + => SendAndBuildAsync(new FlowDebugRunMethodCommand(apiMethodId, parameters), RunAskTimeout, cancellationToken); + + /// + public Task RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default) + => SendAndBuildAsync(new FlowDebugRunValueChangeCommand(instanceId, attributeName, value), RunAskTimeout, cancellationToken); + + /// + public Task ReplayAsync(Guid executionId, CancellationToken cancellationToken = default) + => SendAndBuildAsync(new FlowDebugReplayCommand(executionId), ReplayAskTimeout, cancellationToken); + + /// + /// Resolves instanceCode.scriptName to a script body: instance by unique name → + /// its template chain (nearest definition wins) → the named script's code. + /// + private async Task ResolveSiteScriptAsync(string instanceCode, string scriptName, CancellationToken ct) + { + var instance = await _templates.GetInstanceByUniqueNameAsync(instanceCode, ct); + if (instance == null) + return null; + + var scripts = await CollectEffectiveScriptsAsync(instance.TemplateId, ct); + return scripts.TryGetValue(scriptName, out var script) ? script.Code : null; + } + + /// + /// Walks the template parent chain from upward, collecting + /// scripts by name with the nearest (most-derived) definition winning — so an override in + /// a derived template is what gets predicted, and a base-only script is still found. + /// + private async Task> CollectEffectiveScriptsAsync(int templateId, CancellationToken ct) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + int? current = templateId; + var hops = 0; + var seen = new HashSet(); + while (current is int id && hops++ < MaxTemplateChain && seen.Add(id)) + { + var scripts = await _templates.GetScriptsByTemplateIdAsync(id, ct); + foreach (var s in scripts) + { + if (!result.ContainsKey(s.Name)) + result[s.Name] = s; + } + + var template = await _templates.GetTemplateByIdAsync(id, ct); + current = template?.ParentTemplateId; + } + + return result; + } + + /// True when the script's trigger is a value-change on (case-insensitive). + private static bool IsValueChangeOn(TemplateScript script, string attributeName) + { + if (!string.Equals(script.TriggerType, "valuechange", StringComparison.OrdinalIgnoreCase) + && !string.Equals(script.TriggerType, "ValueChange", StringComparison.OrdinalIgnoreCase)) + return false; + + var cfg = script.TriggerConfiguration; + if (string.IsNullOrWhiteSpace(cfg)) + return false; + + try + { + using var doc = JsonDocument.Parse(cfg); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return false; + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.NameEquals("attribute") || prop.NameEquals("Attribute") + || prop.NameEquals("attributeName") || prop.NameEquals("AttributeName")) + { + return prop.Value.ValueKind == JsonValueKind.String + && string.Equals(prop.Value.GetString(), attributeName, StringComparison.OrdinalIgnoreCase); + } + } + } + catch (JsonException) + { + // Malformed trigger config — not something prediction should fail over. + } + + return false; + } + + private async Task SendAndBuildAsync(object command, TimeSpan timeout, CancellationToken ct) + { + var response = await SendAsync(command, timeout, ct); + switch (response) + { + case ManagementSuccess success: + { + FlowDebugRunResult? result; + try + { + result = JsonSerializer.Deserialize(success.JsonData, ResultJsonOptions); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Flow Debugger result could not be parsed for {Command}", command.GetType().Name); + return FlowDebugOutcome.Fail("The server returned an unreadable result.", "BAD_RESULT"); + } + + if (result == null) + return FlowDebugOutcome.Fail("The server returned no result.", "BAD_RESULT"); + + var graph = FlowGraphBuilder.Build(result.Events ?? [], result.Truncated); + return new FlowDebugOutcome(graph, result, null, null); + } + case ManagementUnauthorized unauthorized: + return FlowDebugOutcome.Fail(unauthorized.Message, "UNAUTHORIZED"); + case ManagementError error: + return FlowDebugOutcome.Fail(error.Error, error.ErrorCode); + default: + return FlowDebugOutcome.Fail("Unexpected response from the management service.", "INTERNAL_ERROR"); + } + } + + /// + /// Wraps in a for the current + /// principal and Asks the ManagementActor. Transport faults become a synthetic + /// so callers handle one response shape. + /// + private async Task SendAsync(object command, TimeSpan timeout, CancellationToken cancellationToken) + { + var actor = _holder.ActorRef; + if (actor is null) + return new ManagementError(string.Empty, "Management service is not ready.", "SERVICE_UNAVAILABLE"); + + var user = await BuildAuthenticatedUserAsync(); + var envelope = new ManagementEnvelope(user, command, Guid.NewGuid().ToString("N")); + + try + { + return await actor.Ask(envelope, timeout, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "ManagementActor Ask failed for {Command}", command.GetType().Name); + return new ManagementError(string.Empty, ex.Message, "TRANSPORT_ERROR"); + } + } + + private async Task BuildAuthenticatedUserAsync() + { + var state = await _auth.GetAuthenticationStateAsync(); + var principal = state.User; + + var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown"; + var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username; + var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToArray(); + var permittedSiteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToArray(); + + return new AuthenticatedUser(username, displayName, roles, permittedSiteIds); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs new file mode 100644 index 00000000..7376094a --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// Outcome of a Flow Debugger run or replay as the page consumes it: the built +/// (null on failure), the raw server result (null when the command +/// itself was refused), and a classified error. +/// +/// The flow graph built from the returned audit events; null when the command failed outright. +/// The server's ; null when no result came back. +/// Human-readable failure, null on success. +/// Machine-readable failure code (STANDBY_NODE, NO_TRIGGER_WITHIN_WINDOW, UNAUTHORIZED, SERVICE_UNAVAILABLE, …), null on success. +public sealed record FlowDebugOutcome(FlowGraph? Graph, FlowDebugRunResult? Raw, string? Error, string? ErrorCode) +{ + /// True when a graph was built (the run may still have finished incomplete — see ). + public bool Success => Graph != null && Error == null; + + /// Creates a failed outcome. + public static FlowDebugOutcome Fail(string error, string errorCode) => new(null, null, error, errorCode); +} + +/// +/// CentralUI facade over the Flow Debugger (design 2026-09-05 §5.5/§7.1): static shape +/// prediction from script syntax, and the three management commands (Live run of a method, +/// Live run of a value change, Replay) dispatched to the per-node ManagementActor +/// through the in-process ManagementActorHolder — the SAME Ask path the HTTP +/// /management endpoint uses, so the server stays the single enforcer of the Deployer +/// gate and the active-node refusal (STANDBY_NODE). +/// +public interface IFlowDebugService +{ + /// Predicts the flow shape of an inbound API method from its script and the site scripts its constant routes resolve to. + /// The method id. + /// Cancellation token. + /// The predicted flow; when the method does not exist. + Task PredictMethodAsync(int apiMethodId, CancellationToken cancellationToken = default); + + /// + /// Predicts the flow a value change on would trigger: the + /// instance's template scripts whose value-change trigger names that attribute, each predicted + /// as a site script. + /// + /// The instance id. + /// Canonical attribute name. + /// Cancellation token. + Task PredictValueChangeAsync(int instanceId, string attributeName, CancellationToken cancellationToken = default); + + /// Runs an inbound API method with tracing on the active central node and returns the flow. + /// The method id. + /// Flat JSON object of parameters, or null. + /// Cancellation token. + Task RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default); + + /// Writes an attribute through the traced path and returns the flow the change triggered. + /// The instance id. + /// Canonical attribute name. + /// Value in canonical string form. + /// Cancellation token. + Task RunValueChangeAsync(int instanceId, string attributeName, string value, CancellationToken cancellationToken = default); + + /// Rebuilds a past flow from the central audit store. + /// Any execution id in the chain. + /// Cancellation token. + Task ReplayAsync(Guid executionId, CancellationToken cancellationToken = default); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs new file mode 100644 index 00000000..5cb04144 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs @@ -0,0 +1,276 @@ +using System.Security.Claims; +using System.Text.Json; +using Akka.Actor; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.ManagementService; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; + +/// +/// Tests for (Flow Debugger design 2026-09-05 §5.5/§7.1): +/// the management-command bridge (holder null, error mapping, success → graph) and the +/// prediction resolver wiring over substituted repositories. +/// +public sealed class FlowDebugServiceTests : IDisposable +{ + private static readonly JsonSerializerOptions ServerJson = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly ActorSystem _system = ActorSystem.Create("flow-debug-service-tests"); + private readonly IInboundApiRepository _inboundApi = Substitute.For(); + private readonly ITemplateEngineRepository _templates = Substitute.For(); + private readonly List _received = new(); + + public void Dispose() => _system.Terminate().Wait(TimeSpan.FromSeconds(5)); + + private FlowDebugService CreateService(object? reply, string role = "Deployer") + { + var holder = new ManagementActorHolder(); + if (reply != null) + holder.ActorRef = _system.ActorOf(Props.Create(() => new ScriptedManagementActor(reply, _received))); + + var claims = new[] + { + new Claim(JwtTokenService.UsernameClaimType, "tester"), + new Claim(JwtTokenService.RoleClaimType, role), + }; + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, NullLogger.Instance); + } + + private static string SuccessPayload(int events, bool completed = true, bool truncated = false) + { + var root = Guid.NewGuid(); + var child = Guid.NewGuid(); + var t0 = DateTime.UtcNow; + var list = new List + { + ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, + occurredAtUtc: t0, target: "POST /api/M", executionId: root, durationMs: 100, + requestSummary: """{"MachineCode":"Z28061"}""", responseSummary: """{"Result":true}"""), + }; + if (events > 1) + list.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(1), target: "I.S", executionId: root, durationMs: 90, + extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson())); + if (events > 2) + list.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(2), target: "S", executionId: child, parentExecutionId: root, + sourceSiteId: "site-a", sourceInstanceId: "I", durationMs: 80)); + + var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1)); + return JsonSerializer.Serialize(result, ServerJson); + } + + [Fact] + public async Task RunMethod_HolderNull_ReportsServiceUnavailable() + { + var svc = CreateService(reply: null); + + var outcome = await svc.RunMethodAsync(4, null); + + Assert.False(outcome.Success); + Assert.Null(outcome.Graph); + Assert.Equal("SERVICE_UNAVAILABLE", outcome.ErrorCode); + Assert.Empty(_received); + } + + [Fact] + public async Task RunMethod_ManagementError_MapsCodeAndMessageVerbatim() + { + var svc = CreateService(new ManagementError("c", "This node is standby.", "STANDBY_NODE")); + + var outcome = await svc.RunMethodAsync(4, null); + + Assert.False(outcome.Success); + Assert.Equal("STANDBY_NODE", outcome.ErrorCode); + Assert.Equal("This node is standby.", outcome.Error); + } + + [Fact] + public async Task RunMethod_Unauthorized_MapsToUnauthorizedCode() + { + var svc = CreateService(new ManagementUnauthorized("c", "Deployer role required."), role: "Viewer"); + + var outcome = await svc.RunMethodAsync(4, null); + + Assert.Equal("UNAUTHORIZED", outcome.ErrorCode); + Assert.Equal("Deployer role required.", outcome.Error); + } + + [Fact] + public async Task RunMethod_Success_BuildsGraphWithTheReturnedSteps_AndSendsTheTypedCommandForThePrincipal() + { + var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3))); + using var doc = JsonDocument.Parse("""{"MachineCode":"Z28061"}"""); + + var outcome = await svc.RunMethodAsync(4, doc.RootElement.Clone()); + + Assert.True(outcome.Success); + Assert.NotNull(outcome.Graph); + Assert.NotNull(outcome.Raw); + Assert.True(outcome.Raw!.Completed); + // 3 returned events + the synthesized Response node. + Assert.Equal(4, outcome.Graph!.Steps.Count); + Assert.Contains(outcome.Graph.Steps, s => s.Kind == FlowStepKind.ScriptRun && s.Lane == FlowLane.Site); + + var envelope = Assert.Single(_received); + var cmd = Assert.IsType(envelope.Command); + Assert.Equal(4, cmd.ApiMethodId); + Assert.Equal("Z28061", cmd.Parameters!.Value.GetProperty("MachineCode").GetString()); + Assert.Equal("tester", envelope.User.Username); + Assert.Contains("Deployer", envelope.User.Roles); + } + + [Fact] + public async Task RunValueChange_AndReplay_SendTheirCommands() + { + var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1))); + var exec = Guid.NewGuid(); + + var vc = await svc.RunValueChangeAsync(7, "Speed", "42"); + var rp = await svc.ReplayAsync(exec); + + Assert.True(vc.Success); + Assert.True(rp.Success); + Assert.Equal(2, _received.Count); + var vcCmd = Assert.IsType(_received[0].Command); + Assert.Equal((7, "Speed", "42"), (vcCmd.InstanceId, vcCmd.AttributeName, vcCmd.Value)); + var rpCmd = Assert.IsType(_received[1].Command); + Assert.Equal(exec, rpCmd.ExecutionId); + } + + [Fact] + public async Task Run_TruncatedResult_FlagsTheGraph() + { + var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 2, truncated: true))); + + var outcome = await svc.RunMethodAsync(4, null); + + Assert.True(outcome.Success); + Assert.True(outcome.Graph!.Truncated); + } + + [Fact] + public async Task PredictMethod_ResolvesConstantRouteTargets_ThroughInstanceTemplateChain() + { + _inboundApi.GetApiMethodByIdAsync(4, Arg.Any()) + .Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 }); + _templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any()) + .Returns(new Instance("Sim01") { Id = 9, TemplateId = 20, SiteId = 1 }); + // Derived template 20 has no scripts of its own; its base 10 defines Process. + _templates.GetScriptsByTemplateIdAsync(20, Arg.Any()) + .Returns(Task.FromResult>([])); + _templates.GetTemplateByIdAsync(20, Arg.Any()) + .Returns(new Template("Derived") { Id = 20, ParentTemplateId = 10 }); + _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) + .Returns(Task.FromResult>( + [ + new TemplateScript("Process", """await Attributes.SetAsync("Cmd", 1); await Attributes.WaitAsync("Ack", true, System.TimeSpan.FromSeconds(1));""") { Id = 1, TemplateId = 10 }, + ])); + _templates.GetTemplateByIdAsync(10, Arg.Any()) + .Returns(new Template("Base") { Id = 10, ParentTemplateId = null }); + + var svc = CreateService(reply: null); + var flow = await svc.PredictMethodAsync(4); + + Assert.Equal( + [ + FlowStepKind.InboundRequest, FlowStepKind.RouteCall, FlowStepKind.ScriptRun, + FlowStepKind.AttributeWrite, FlowStepKind.AttributeWait, FlowStepKind.Response, + ], flow.Nodes.Select(n => n.Kind).ToList()); + Assert.Equal("Sim01.Process", flow.Nodes[2].Title); + Assert.Equal("Sim01.Cmd", flow.Nodes[3].Title); + } + + [Fact] + public async Task PredictMethod_UnknownMethod_IsEmpty() + { + _inboundApi.GetApiMethodByIdAsync(99, Arg.Any()).Returns((ApiMethod?)null); + var svc = CreateService(reply: null); + + var flow = await svc.PredictMethodAsync(99); + + Assert.Empty(flow.Nodes); + } + + [Fact] + public async Task PredictValueChange_RootsAtTheWrite_AndAppendsEachTriggeredScript() + { + _templates.GetInstanceByIdAsync(9, Arg.Any()) + .Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }); + _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) + .Returns(Task.FromResult>( + [ + new TemplateScript("OnFlag", """Attributes["Ack"] = true;""") { Id = 1, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Flag"}""" }, + new TemplateScript("OnOther", "return 1;") { Id = 2, TemplateId = 10, TriggerType = "valuechange", TriggerConfiguration = """{"attributeName":"Other"}""" }, + new TemplateScript("Manual", "return 1;") { Id = 3, TemplateId = 10, TriggerType = "call" }, + ])); + _templates.GetTemplateByIdAsync(10, Arg.Any()) + .Returns(new Template("Base") { Id = 10 }); + + var svc = CreateService(reply: null); + var flow = await svc.PredictValueChangeAsync(9, "Flag"); + + Assert.Equal( + [ + (FlowStepKind.AttributeWrite, "Sim01.Flag"), + (FlowStepKind.ScriptRun, "Sim01.OnFlag"), + (FlowStepKind.AttributeWrite, "Ack"), + ], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList()); + Assert.Equal(2, flow.Edges.Count); + Assert.Equal(("n1", "n2"), (flow.Edges[0].From, flow.Edges[0].To)); + Assert.Equal(("n2", "n3"), (flow.Edges[1].From, flow.Edges[1].To)); + } + + [Fact] + public async Task PredictValueChange_NoTriggeredScript_NotesIt() + { + _templates.GetInstanceByIdAsync(9, Arg.Any()) + .Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }); + _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) + .Returns(Task.FromResult>([])); + _templates.GetTemplateByIdAsync(10, Arg.Any()).Returns(new Template("Base") { Id = 10 }); + + var svc = CreateService(reply: null); + var flow = await svc.PredictValueChangeAsync(9, "Flag"); + + var root = Assert.Single(flow.Nodes); + Assert.Contains("no value-change script", root.Note); + } + + private sealed class StubAuth : AuthenticationStateProvider + { + private readonly ClaimsPrincipal _user; + public StubAuth(ClaimsPrincipal user) => _user = user; + public override Task GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user)); + } + + private sealed class ScriptedManagementActor : ReceiveActor + { + public ScriptedManagementActor(object reply, List received) + { + Receive(env => + { + lock (received) received.Add(env); + Sender.Tell(reply); + }); + } + } +}