using System.Security.Claims; using System.Text.Json; using Akka.Actor; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; 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.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; 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.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.DeploymentManager; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; 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), the /// prediction resolver wiring over substituted repositories, the script entry (design /// 2026-09-08 §5 — script list, prediction and run over the flattened configuration), and /// the Retry/Discard relay for a parked outbound node (follow-ups plan 2026-09-06 D4). /// /// /// is a concrete class with non-virtual methods, so the /// relay tests wire its two central-local proxies (SetSiteCallAudit / /// SetNotificationOutbox) to scripted s on the test's /// — the same seam SiteCallsReportPageTests and /// NotificationReportPageTests use. /// 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 IFlatteningPipeline _pipeline = Substitute.For(); private readonly List _received = new(); // Relay probes: everything the scripted Site Call Audit / Notification Outbox actors receive. private readonly List _siteCallRequests = new(); private readonly List _outboxRequests = new(); // Scripted relay replies — overridable per test before the relay call. private SiteCallRelayOutcome _siteCallOutcome = SiteCallRelayOutcome.Applied; private bool _siteReachable = true; private string? _siteCallError; 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")); var comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); comms.SetSiteCallAudit(_system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)))); comms.SetNotificationOutbox(_system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)))); return new FlowDebugService(holder, new StubAuth(principal), _inboundApi, _templates, _pipeline, comms, NullLogger.Instance); } /// /// Wires the instance row plus the flattened configuration its scripts are read from /// (script entry, design 2026-09-08 §2 — the picker and the prediction both go through /// , never the raw template chain). /// private void WireFlattened(int instanceId, string uniqueName, params ResolvedScript[] scripts) { _templates.GetInstanceByIdAsync(instanceId, Arg.Any()) .Returns(new Instance(uniqueName) { Id = instanceId, TemplateId = 10, SiteId = 1 }); var cfg = new FlattenedConfiguration { InstanceUniqueName = uniqueName, TemplateId = 10, SiteId = 1, Scripts = scripts }; _pipeline.FlattenAndValidateAsync(instanceId, Arg.Any(), false, null) .Returns(Result.Success(new FlatteningPipelineResult(cfg, "h", new ValidationResult()))); } /// An External-lane step as FlowStepProjector would project it (positional per ). private static FlowStep OutboundStep(FlowStepKind kind, FlowStepOutcome outcome, Guid? correlationId, string? siteId) => new( Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External, "ERP.GetOrder", null, DateTimeOffset.UtcNow, null, outcome, null, null, null, siteId, "node-a", "Sim01", "ERP.GetOrder", null, Lifecycle: null, CorrelationId: correlationId); [Fact] public async Task RetryOutbound_ApiCallParked_RelaysRetrySiteCallWithCorrelationAndSite() { var c = Guid.NewGuid(); var svc = CreateService(reply: null); var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, c, "site-a")); Assert.True(result.Success, result.Message); Assert.Contains("relayed", result.Message); Assert.Contains("site-a", result.Message); var request = Assert.IsType(Assert.Single(_siteCallRequests)); Assert.Equal(c, request.TrackedOperationId); Assert.Equal("site-a", request.SourceSite); Assert.Equal("tester", request.RequestedBy); Assert.Empty(_outboxRequests); } [Fact] public async Task RetryOutbound_Notify_RelaysRetryNotification() { var c = Guid.NewGuid(); var svc = CreateService(reply: null); var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.Notify, FlowStepOutcome.Parked, c, siteId: null)); Assert.True(result.Success, result.Message); Assert.Contains("re-queued", result.Message); var request = Assert.IsType(Assert.Single(_outboxRequests)); Assert.Equal(c.ToString("N"), request.NotificationId); Assert.Equal("tester", request.RequestedBy); Assert.Empty(_siteCallRequests); } [Fact] public async Task DiscardOutbound_DbCall_RelaysDiscardSiteCall() { var c = Guid.NewGuid(); var svc = CreateService(reply: null); var result = await svc.DiscardOutboundAsync(OutboundStep(FlowStepKind.DbCall, FlowStepOutcome.Parked, c, "site-b")); Assert.True(result.Success, result.Message); Assert.Contains("Discard of", result.Message); Assert.Contains("site-b", result.Message); var request = Assert.IsType(Assert.Single(_siteCallRequests)); Assert.Equal(c, request.TrackedOperationId); Assert.Equal("site-b", request.SourceSite); Assert.Equal("tester", request.RequestedBy); } [Fact] public async Task RetryOutbound_NotParked_RefusesWithoutRelay() { var svc = CreateService(reply: null); var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Ok, Guid.NewGuid(), "site-a")); Assert.False(result.Success); Assert.Contains("not parked", result.Message); Assert.Empty(_siteCallRequests); Assert.Empty(_outboxRequests); } [Fact] public async Task RetryOutbound_NoCorrelation_Refuses() { var svc = CreateService(reply: null); var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, correlationId: null, "site-a")); Assert.False(result.Success); Assert.Contains("no tracked operation id", result.Message); Assert.Empty(_siteCallRequests); Assert.Empty(_outboxRequests); } [Fact] public async Task RetryOutbound_SiteKindWithoutSiteId_Refuses() { var svc = CreateService(reply: null); var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.DbCall, FlowStepOutcome.Parked, Guid.NewGuid(), siteId: " ")); Assert.False(result.Success); Assert.Contains("owning site", result.Message); Assert.Empty(_siteCallRequests); Assert.Empty(_outboxRequests); } [Fact] public async Task RetryOutbound_NonOutboundStep_Refuses() { var svc = CreateService(reply: null); var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ScriptRun, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a")); Assert.False(result.Success); Assert.Contains("Only an API call", result.Message); Assert.Empty(_siteCallRequests); Assert.Empty(_outboxRequests); } [Fact] public async Task RetryOutbound_SiteUnreachable_MapsToFailureMessage() { _siteCallOutcome = SiteCallRelayOutcome.SiteUnreachable; _siteReachable = false; var svc = CreateService(reply: null); var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a")); Assert.False(result.Success); Assert.Contains("unreachable", result.Message); Assert.Single(_siteCallRequests); } [Fact] public async Task RetryOutbound_NotParkedAtSite_MapsToFailureMessage() { _siteCallOutcome = SiteCallRelayOutcome.NotParked; var svc = CreateService(reply: null); var result = await svc.RetryOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a")); Assert.False(result.Success); Assert.Contains("no longer parked", result.Message); } [Fact] public async Task DiscardOutbound_OperationFailed_UsesTheSitesOwnErrorMessage() { _siteCallOutcome = SiteCallRelayOutcome.OperationFailed; _siteCallError = "S&F buffer is locked by a sweep."; var svc = CreateService(reply: null); var result = await svc.DiscardOutboundAsync(OutboundStep(FlowStepKind.ApiCall, FlowStepOutcome.Parked, Guid.NewGuid(), "site-a")); Assert.False(result.Success); Assert.Equal("S&F buffer is locked by a sweep.", result.Message); Assert.IsType(Assert.Single(_siteCallRequests)); } 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); } [Theory] [InlineData(180, 205)] [InlineData(1, 30)] public void RunAskTimeoutFor_IsMethodTimeoutPlusRunGracePlusClientSlack_FlooredAtMinRunAskTimeout(int timeoutSeconds, int expectedSeconds) { var timeout = FlowDebugService.RunAskTimeoutFor(timeoutSeconds); Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), timeout); } [Fact] public void ValueChangeAskTimeoutFor_IsWaitSecondsPlusClientSlack() { var timeout = FlowDebugService.ValueChangeAskTimeoutFor(45); Assert.Equal(TimeSpan.FromSeconds(60), timeout); } [Fact] public async Task RunWriteKindScript_AndReplay_SendTheirCommands() { WireFlattened(7, "Sim01", new ResolvedScript { CanonicalName = "OnSpeed", Code = "x", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Speed\"}" }); var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 1))); var exec = Guid.NewGuid(); var vc = await svc.RunScriptAsync(7, "OnSpeed", null, "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, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value)); // A write kind fires through the attribute, so it carries no parameters. Assert.Null(vcCmd.Parameters); 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); } /// /// Regression for the 2026-09-06 circuit deadlock: /// runs on Blazor Server's single-threaded renderer sync context, and the route-target resolver used /// to block on the repository with GetAwaiter().GetResult() from inside the synchronous /// predictor walk. A repository continuation posted back to that context could then never run — /// the whole circuit hung (every later click / JS-interop reply queued behind the blocked dispatcher). /// The pump below IS that context: one thread, posts queued, a blocked callback starves the queue. /// [Fact] public void PredictMethod_OnASingleThreadedSyncContext_NeverBlocksTheDispatcher() { _inboundApi.GetApiMethodByIdAsync(4, Arg.Any()) .Returns(new ApiMethod("Delmia", """return await Route.To("Sim01").Call("Process", new { });""") { Id = 4 }); // The instance lookup completes only after a Yield — i.e. only when the sync context gets to // run the continuation. Blocking on it from the context's own thread is the deadlock. _templates.GetInstanceByUniqueNameAsync("Sim01", Arg.Any()) .Returns(_ => YieldThen(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 })); _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) .Returns(Task.FromResult>( [ new TemplateScript("Process", """await Attributes.SetAsync("Cmd", 1);""") { Id = 1, TemplateId = 10 }, ])); _templates.GetTemplateByIdAsync(10, Arg.Any()) .Returns(new Template("Base") { Id = 10, ParentTemplateId = null }); var svc = CreateService(reply: null); var pump = new SingleThreadPump(); PredictedFlow? flow = null; Exception? failure = null; var done = new ManualResetEventSlim(); var thread = new Thread(() => { try { flow = pump.Run(() => svc.PredictMethodAsync(4)); } catch (Exception ex) { failure = ex; } finally { done.Set(); } }) { IsBackground = true, Name = "flow-predict-pump" }; thread.Start(); Assert.True(done.Wait(TimeSpan.FromSeconds(10)), "PredictMethodAsync deadlocked the single-threaded sync context (sync-over-async in the route resolver)."); Assert.Null(failure); Assert.NotNull(flow); Assert.Contains(flow!.Nodes, n => n.Kind == FlowStepKind.ScriptRun && n.Title == "Sim01.Process"); Assert.Contains(flow.Nodes, n => n.Kind == FlowStepKind.AttributeWrite && n.Title == "Sim01.Cmd"); } [Fact] public async Task PredictMethod_NestedRouteTargets_ResolveAcrossPasses() { // Process calls a second script on the same instance via Scripts.Call — discovered only once // Process itself has been resolved, so the service needs a second resolve-and-rewalk pass. _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 = 10, SiteId = 1 }); _templates.GetScriptsByTemplateIdAsync(10, Arg.Any()) .Returns(Task.FromResult>( [ new TemplateScript("Process", """await Scripts.Call("Inner", new { });""") { Id = 1, TemplateId = 10 }, new TemplateScript("Inner", """await Attributes.SetAsync("Cmd", 1);""") { Id = 2, 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); var kinds = flow.Nodes.Select(n => n.Kind).ToList(); Assert.Contains(FlowStepKind.AttributeWrite, kinds); Assert.Contains(flow.Nodes, n => n.Title == "Sim01.Inner"); Assert.Contains(flow.Nodes, n => n.Title == "Sim01.Cmd"); } private static async Task YieldThen(Instance instance) { await Task.Yield(); return instance; } /// A Blazor-Server-shaped sync context: a single thread draining a queue of posted callbacks. private sealed class SingleThreadPump : SynchronizationContext { private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new(); public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state)); public override void Send(SendOrPostCallback d, object? state) => throw new NotSupportedException(); public T Run(Func> body) { var previous = Current; SetSynchronizationContext(this); try { var task = body(); while (!task.IsCompleted) { var (callback, state) = _queue.Take(); callback(state); } return task.GetAwaiter().GetResult(); } finally { SetSynchronizationContext(previous); } } } [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 GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName() { WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "return 1;", TriggerType = "Call", ParameterDefinitions = "{\"type\":\"object\"}", ExecutionTimeoutSeconds = 12 }, new ResolvedScript { CanonicalName = "OnFlag", Code = "x", TriggerType = "valuechange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" }, new ResolvedScript { CanonicalName = "Mod.Tick", Code = "x", TriggerType = "Interval", TriggerConfiguration = "{\"intervalMs\":1000}" }); var options = await CreateService(reply: null).GetInstanceScriptsAsync(9); Assert.Equal(["Manual", "Mod.Tick", "OnFlag"], options.Select(o => o.CanonicalName).ToList()); var onFlag = options.Single(o => o.CanonicalName == "OnFlag"); Assert.True(onFlag.IsWriteKind); Assert.Equal("Flag", onFlag.TriggerAttribute); Assert.Equal("valuechange: Flag", onFlag.TriggerLabel); var manual = options.Single(o => o.CanonicalName == "Manual"); Assert.False(manual.IsWriteKind); Assert.Equal(12, manual.ExecutionTimeoutSeconds); Assert.Equal("Call", manual.TriggerLabel); Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind); } [Fact] public async Task GetInstanceScripts_UnknownInstance_IsEmpty() { Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404)); } [Fact] public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript() { WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "OnFlag", Code = """Attributes["Ack"] = true;""", TriggerType = "ValueChange", TriggerConfiguration = "{\"attributeName\":\"Flag\"}" }); var flow = await CreateService(reply: null).PredictScriptAsync(9, "OnFlag"); Assert.Equal( [ (FlowStepKind.AttributeWrite, "Sim01.Flag"), (FlowStepKind.ScriptRun, "Sim01.OnFlag"), (FlowStepKind.AttributeWrite, "Ack"), ], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList()); } [Fact] public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript() { WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = """Attributes["Ack"] = true;""", TriggerType = "Call" }); var flow = await CreateService(reply: null).PredictScriptAsync(9, "Manual"); Assert.Equal(FlowStepKind.RouteCall, flow.Nodes[0].Kind); Assert.Equal(FlowLane.Central, flow.Nodes[0].Lane); Assert.Equal("Sim01.Manual", flow.Nodes[0].Title); Assert.Equal(FlowStepKind.ScriptRun, flow.Nodes[1].Kind); Assert.True(flow.Edges[0].CrossesBoundary); } [Fact] public async Task PredictScript_UnknownScript_IsEmpty() { WireFlattened(9, "Sim01"); Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope")); } [Fact] public async Task RunScript_SendsTheCommand_WithTheScriptShape() { WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 }); var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson))); using var doc = JsonDocument.Parse("""{"n":3}"""); var outcome = await svc.RunScriptAsync(9, "Manual", doc.RootElement.Clone(), null); Assert.True(outcome.Success, outcome.Error); var env = Assert.Single(_received); var cmd = Assert.IsType(env.Command); Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName)); // A call kind carries its parameters verbatim and no value. Assert.Equal(3, cmd.Parameters!.Value.GetProperty("n").GetInt32()); Assert.Null(cmd.Value); } /// /// One picker selection costs ONE flatten: the prediction and the run that follow it read the /// entry just refreshed (design /// 2026-09-08 §5). A regression here means every keystroke re-walks the template chain. /// [Fact] public async Task GetInstanceScripts_ThenPredict_ThenRun_FlattenOnce() { WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" }); var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson))); await svc.GetInstanceScriptsAsync(9); await svc.PredictScriptAsync(9, "Manual"); await svc.RunScriptAsync(9, "Manual", null, null); await _pipeline.Received(1).FlattenAndValidateAsync(9, Arg.Any(), false, null); } /// /// A scoped service lives for the whole Blazor circuit, so re-selecting the instance must /// re-flatten — otherwise a template edit or a redeploy mid-session leaves the picker, the /// parameter form and the Ask sizing stale for the rest of the session. /// [Fact] public async Task GetInstanceScripts_CalledAgain_ReflattensAndRefreshes() { _templates.GetInstanceByIdAsync(9, Arg.Any()) .Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 }); static Result Flattened(params ResolvedScript[] scripts) => Result.Success(new FlatteningPipelineResult( new FlattenedConfiguration { InstanceUniqueName = "Sim01", TemplateId = 10, SiteId = 1, Scripts = scripts }, "h", new ValidationResult())); _pipeline.FlattenAndValidateAsync(9, Arg.Any(), false, null).Returns( Flattened(new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" }), Flattened( new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" }, new ResolvedScript { CanonicalName = "Added", Code = "x", TriggerType = "Call" })); var svc = CreateService(reply: null); var first = await svc.GetInstanceScriptsAsync(9); var second = await svc.GetInstanceScriptsAsync(9); Assert.Equal(["Manual"], first.Select(o => o.CanonicalName).ToList()); Assert.Equal(["Added", "Manual"], second.Select(o => o.CanonicalName).ToList()); await _pipeline.Received(2).FlattenAndValidateAsync(9, Arg.Any(), false, null); } 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); }); } } /// Stands in for the Site Call Audit singleton proxy: records the relay request, replies as scripted. private sealed class ScriptedSiteCallAuditActor : ReceiveActor { public ScriptedSiteCallAuditActor(FlowDebugServiceTests test) { Receive(r => { lock (test._siteCallRequests) test._siteCallRequests.Add(r); Sender.Tell(new RetrySiteCallResponse(r.CorrelationId, test._siteCallOutcome, test._siteCallOutcome == SiteCallRelayOutcome.Applied, test._siteReachable, test._siteCallError)); }); Receive(r => { lock (test._siteCallRequests) test._siteCallRequests.Add(r); Sender.Tell(new DiscardSiteCallResponse(r.CorrelationId, test._siteCallOutcome, test._siteCallOutcome == SiteCallRelayOutcome.Applied, test._siteReachable, test._siteCallError)); }); } } /// Stands in for the Notification Outbox singleton proxy: records the request, replies success. private sealed class ScriptedOutboxActor : ReceiveActor { public ScriptedOutboxActor(FlowDebugServiceTests test) { Receive(r => { lock (test._outboxRequests) test._outboxRequests.Add(r); Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)); }); Receive(r => { lock (test._outboxRequests) test._outboxRequests.Add(r); Sender.Tell(new DiscardNotificationResponse(r.CorrelationId, true, null)); }); } } }