using Bunit; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// /// Flow Debugger outbound-calls plan (2026-09-06) Task 8: the step detail panel's Lifecycle tab, /// outbound "Where this ran" fields and kind-specific Input tab labels. Follow-ups plan (2026-09-06) /// Task 6: report deep links from a correlated outbound step and Retry/Discard on a parked one (D2/D5). /// Step-logs plan (2026-09-09) Task 12: the Log tab โ€” exception block, level-filtered lines and the /// cross-step hint (design D8). /// public class FlowStepDetailTests : BunitContext { private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero); public FlowStepDetailTests() { // The Log tab's Copy buttons call navigator.clipboard through IJSRuntime. JSInterop.Mode = JSRuntimeMode.Loose; } private const string ExceptionDetail = "System.InvalidOperationException: Recipe 42 rejected by Galaxy (code 7)\n" + " at Submission#0.<>d__0.MoveNext() in ProcessRecipeDownload:line 41\n" + " at ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRunLauncher.RunScriptAsync()"; /// A site script-run step, optionally carrying folded log lines and a thrown exception's detail. private static FlowStep ScriptStep( FlowStepOutcome outcome, string title, Guid executionId, IReadOnlyList? logs = null, string? errorDetail = null) => new(Guid.NewGuid(), executionId, null, FlowStepKind.ScriptRun, FlowLane.Site, title, "DelmiaSim01.DelmiaReceiver \u00b7 call", T0, 1731, outcome, "{\"RecipeId\":42}", "{\"ok\":false}", outcome == FlowStepOutcome.Failed ? "Recipe 42 rejected by Galaxy (code 7)" : null, "site-a", "node-a", "DelmiaSim01", title, null, null, null, errorDetail, logs); private static FlowLogLine Line( string level, string message, string source = "script", int seq = 0, string? exceptionMessage = null, string? exceptionDetail = null) => new(Guid.NewGuid(), null, T0.AddMilliseconds(seq), level, source, message, exceptionMessage, exceptionDetail, null, seq); private static FlowStep Step(FlowStepKind kind, FlowStepOutcome outcome, string target, IReadOnlyList? lifecycle = null, Guid? correlationId = null) => new(Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External, target, "Delivered ยท cached", T0, 4100, outcome, "{\"id\":1}", "{\"ok\":true}", outcome == FlowStepOutcome.Failed ? "boom" : null, "site-a", "node-a", "M1", target, null, lifecycle, correlationId); private static IReadOnlyList CachedLifecycle(string terminalStatus) => [ new(Guid.NewGuid(), "CachedSubmit", "Submitted", T0, "node-a", null, null), new(Guid.NewGuid(), "ApiCallCached", "Attempted", T0.AddSeconds(3), "node-a", "503 Service Unavailable", 40), new(Guid.NewGuid(), "CachedResolve", terminalStatus, T0.AddMilliseconds(4100), "node-b", null, null), ]; [Fact] public void LifecycleTab_RendersRowsInTimeOrder_AndDeliveredBy() { var step = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe", CachedLifecycle("Delivered")); var cut = Render(p => p.Add(x => x.Step, step)); cut.Find("[data-test=flow-step-tab-lifecycle]").Click(); var rows = cut.FindAll("[data-test=flow-step-lifecycle] tbody tr"); Assert.Equal(3, rows.Count); Assert.Contains("CachedSubmit", rows[0].TextContent); Assert.Contains("Submitted", rows[0].TextContent); Assert.Contains("503 Service Unavailable", rows[1].TextContent); Assert.Contains("CachedResolve", rows[2].TextContent); Assert.Contains("node-b", rows[2].TextContent); Assert.Contains("12:00:04.100", rows[2].TextContent); var where = cut.Find("[data-test=flow-step-where]").TextContent; Assert.Contains("System", where); Assert.Contains("Delmia.AckRecipe", where); Assert.Contains("Delivered by", where); Assert.Contains("node-b", where); Assert.Empty(cut.FindAll("[data-test=flow-step-parked-hint]")); } [Fact] public void NoLifecycle_HidesTheTab() { var step = Step(FlowStepKind.AttributeWrite, FlowStepOutcome.Ok, "A") with { Lane = FlowLane.Tags }; var cut = Render(p => p.Add(x => x.Step, step)); Assert.Empty(cut.FindAll("[data-test=flow-step-tab-lifecycle]")); Assert.Equal("Input", cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim()); } [Fact] public void ParkedNotify_ShowsHint_AndListLabel_AndParkedBadge() { var lifecycle = new List { new(Guid.NewGuid(), "NotifySend", "Submitted", T0, "node-a", null, null), new(Guid.NewGuid(), "NotifyDeliver", "Parked", T0.AddSeconds(9), "central-a", "retries exhausted", null), }; var step = Step(FlowStepKind.Notify, FlowStepOutcome.Parked, "Planners", lifecycle); var cut = Render(p => p.Add(x => x.Step, step)); Assert.Equal("Parked", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim()); Assert.Contains("warning", cut.Find("[data-test=flow-step-outcome]").ClassName); Assert.Equal("Subject + body", cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim()); var where = cut.Find("[data-test=flow-step-where]").TextContent; Assert.Contains("List", where); Assert.Contains("central-a", where); cut.Find("[data-test=flow-step-tab-lifecycle]").Click(); Assert.Contains("Site Calls / Notification", cut.Find("[data-test=flow-step-parked-hint]").TextContent); } [Theory] [InlineData(FlowStepKind.ApiCall, "Request", "System")] [InlineData(FlowStepKind.DbCall, "SQL + parameters", "Connection")] [InlineData(FlowStepKind.Notify, "Subject + body", "List")] public void OutboundKinds_LabelInputTabAndTarget(FlowStepKind kind, string inputLabel, string targetLabel) { var step = Step(kind, FlowStepOutcome.Pending, "T"); var cut = Render(p => p.Add(x => x.Step, step)); Assert.Equal(inputLabel, cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim()); Assert.Contains(targetLabel, cut.Find("[data-test=flow-step-where]").TextContent); Assert.Equal("Pending", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim()); } [Fact] public void OutboundStepWithCorrelation_LinksToTheReportPage() { var c = Guid.NewGuid(); var api = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe", CachedLifecycle("Delivered"), c); var cut = Render(p => p.Add(x => x.Step, api)); var link = cut.Find("a[data-test=open-report]"); Assert.Equal($"/site-calls/report?correlationId={c:D}", link.GetAttribute("href")); Assert.Equal("Open in Site Calls", link.TextContent.Trim()); var notify = Step(FlowStepKind.Notify, FlowStepOutcome.Ok, "Planners", CachedLifecycle("Delivered"), c); cut = Render(p => p.Add(x => x.Step, notify)); link = cut.Find("a[data-test=open-report]"); Assert.Equal($"/notifications/report?correlationId={c:N}", link.GetAttribute("href")); Assert.Equal("Open in Notification report", link.TextContent.Trim()); var sync = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe"); cut = Render(p => p.Add(x => x.Step, sync)); Assert.Empty(cut.FindAll("[data-test=open-report]")); } [Fact] public void ParkedStep_WithCallbacks_ShowsRetryAndDiscard() { var c = Guid.NewGuid(); var step = Step(FlowStepKind.ApiCall, FlowStepOutcome.Parked, "Delmia.AckRecipe", CachedLifecycle("Parked"), c); FlowStep? retried = null; FlowStep? discarded = null; var cut = Render(p => p .Add(x => x.Step, step) .Add(x => x.OnRetry, s => retried = s) .Add(x => x.OnDiscard, s => discarded = s)); var retry = cut.Find("button[data-test=flow-step-retry]"); var discard = cut.Find("button[data-test=flow-step-discard]"); Assert.Equal("Retry", retry.TextContent.Trim()); Assert.Equal("Discard", discard.TextContent.Trim()); Assert.Contains("btn-outline-primary", retry.ClassName); Assert.Contains("btn-outline-danger", discard.ClassName); Assert.False(retry.HasAttribute("disabled")); Assert.False(discard.HasAttribute("disabled")); retry.Click(); Assert.Same(step, retried); Assert.Null(discarded); discard.Click(); Assert.Same(step, discarded); cut.Render(p => p .Add(x => x.Step, step) .Add(x => x.OnRetry, s => retried = s) .Add(x => x.OnDiscard, s => discarded = s) .Add(x => x.ActionInProgress, true)); Assert.True(cut.Find("button[data-test=flow-step-retry]").HasAttribute("disabled")); Assert.True(cut.Find("button[data-test=flow-step-discard]").HasAttribute("disabled")); } [Fact] public void ParkedStep_WithoutCallbacks_ShowsOnlyTheHintLink() { var c = Guid.NewGuid(); var step = Step(FlowStepKind.Notify, FlowStepOutcome.Parked, "Planners", CachedLifecycle("Parked"), c); var cut = Render(p => p.Add(x => x.Step, step)); Assert.Empty(cut.FindAll("[data-test=flow-step-retry]")); Assert.Empty(cut.FindAll("[data-test=flow-step-discard]")); cut.Find("[data-test=flow-step-tab-lifecycle]").Click(); var hint = cut.Find("[data-test=flow-step-parked-hint]"); Assert.Contains("Parked", hint.TextContent); var hintLink = hint.QuerySelector("a"); Assert.NotNull(hintLink); Assert.Equal($"/notifications/report?correlationId={c:N}", hintLink!.GetAttribute("href")); } [Fact] public void OkStep_NeverShowsActions() { var step = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe", CachedLifecycle("Delivered"), Guid.NewGuid()); var cut = Render(p => p .Add(x => x.Step, step) .Add(x => x.OnRetry, _ => { }) .Add(x => x.OnDiscard, _ => { })); Assert.Empty(cut.FindAll("[data-test=flow-step-retry]")); Assert.Empty(cut.FindAll("[data-test=flow-step-discard]")); } [Fact] public void NoLinesAndNoDetail_HidesTheLogTab() { var cut = Render(p => p.Add(x => x.Step, ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid()))); Assert.Empty(cut.FindAll("[data-test=flow-step-tab-log]")); } [Fact] public void LogTab_CountsItsLines() { var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(), [ Line("info", "Recipe 42 requested", seq: 1), Line("debug", "Writing header attributes", seq: 2), Line("warn", "ResultCode 7 is outside the accepted set", seq: 3), ]); var cut = Render(p => p.Add(x => x.Step, step)); Assert.Equal("Log (3)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim()); } [Fact] public void FailedStepWithDetail_OpensOnLog_AndRendersTheExceptionBlock() { var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(), [Line("error", "Rejecting recipe 42 (code 7)", seq: 1)], ExceptionDetail); var cut = Render(p => p.Add(x => x.Step, step)); Assert.Contains("active", cut.Find("[data-test=flow-step-tab-log]").ClassName); var exc = cut.Find("[data-test=flow-step-exception]"); Assert.Equal("System.InvalidOperationException", exc.QuerySelector(".fsd-exc-type")!.TextContent.Trim()); Assert.Equal("Recipe 42 rejected by Galaxy (code 7)", exc.QuerySelector(".fsd-exc-message")!.TextContent.Trim()); Assert.Contains("ScriptRunLauncher.RunScriptAsync", exc.QuerySelector("pre")!.TextContent); Assert.NotNull(cut.Find("[data-test=flow-step-exception-copy]")); } [Fact] public void WrappedException_TheBlockHeaderNamesTheINNERMOSTType_InFull() { // Final review M1: the panel used to parse the OUTERMOST exception of a " ---> " chain // while the graph node's subtitle showed the innermost โ€” the two now share // ExceptionHeader, which always follows the chain to its end. The panel still shows the // type with its namespace (only the node badge shortens it), and the raw detail is // untouched, so the wrapper is still visible in the
.
        const string wrapped =
            "System.AggregateException: One or more errors occurred. (Recipe 42 rejected by Galaxy (code 7)) " +
            "---> ZB.Scada.RecipeRejectedException: Recipe 42 rejected by Galaxy (code 7)\n" +
            "   at Submission#0.<>d__0.MoveNext()";
        var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(), [], wrapped);
        var cut = Render(p => p.Add(x => x.Step, step));

        var exc = cut.Find("[data-test=flow-step-exception]");
        Assert.Equal("ZB.Scada.RecipeRejectedException", exc.QuerySelector(".fsd-exc-type")!.TextContent.Trim());
        Assert.Equal("Recipe 42 rejected by Galaxy (code 7)", exc.QuerySelector(".fsd-exc-message")!.TextContent.Trim());
        Assert.Contains("System.AggregateException", exc.QuerySelector("pre")!.TextContent);
    }

    [Fact]
    public void OkStepWithLines_OpensOnInput()
    {
        var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(), [Line("info", "hello", seq: 1)]);
        var cut = Render(p => p.Add(x => x.Step, step));

        Assert.Contains("active", cut.Find("[data-test=flow-step-tab-input]").ClassName);
        Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
        Assert.Empty(cut.FindAll("[data-test=flow-step-exception]"));
    }

    [Fact]
    public void WarnFilter_LeavesOnlyWarnAndErrorRows()
    {
        var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(),
        [
            Line("info", "Recipe 42 requested", seq: 1),
            Line("debug", "Writing header attributes", seq: 2),
            Line("warn", "ResultCode 7 is outside the accepted set", seq: 3),
            Line("error", "Rejecting recipe 42", seq: 4),
        ]);
        var cut = Render(p => p.Add(x => x.Step, step));
        cut.Find("[data-test=flow-step-tab-log]").Click();

        Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count);

        cut.Find("[data-test=flow-step-log-filter-warn]").Click();
        var rows = cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]");
        Assert.Equal(2, rows.Count);
        Assert.Equal("warn", rows[0].GetAttribute("data-level"));
        Assert.Equal("error", rows[1].GetAttribute("data-level"));

        cut.Find("[data-test=flow-step-log-filter-all]").Click();
        Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count);
    }

    [Fact]
    public void Hint_NamesAnotherStepOfTheSameExecution_AndSelectsIt()
    {
        var exec = Guid.NewGuid();
        var selected = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", exec, [Line("info", "a", seq: 1)]);
        var other = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId, RecipeName", exec, [Line("warn", "acked Uncertain", seq: 2)]);
        var elsewhere = ScriptStep(FlowStepOutcome.Ok, "Another execution", Guid.NewGuid(), [Line("info", "c", seq: 3)]);
        var graph = new FlowGraph([selected, other, elsewhere], [], false, false);
        FlowStep? picked = null;

        var cut = Render(p => p
            .Add(x => x.Step, selected)
            .Add(x => x.Graph, graph)
            .Add(x => x.OnSelectStep, s => picked = s));
        cut.Find("[data-test=flow-step-tab-log]").Click();

        var hint = cut.Find("[data-test=flow-step-log-hint]");
        Assert.Contains("1 more on", hint.TextContent);
        Assert.Contains("Write RecipeId, RecipeName", hint.TextContent);
        Assert.DoesNotContain("Another execution", hint.TextContent);

        hint.QuerySelector("button")!.Click();
        Assert.Same(other, picked);
    }

    [Fact]
    public void Hint_WithoutASelectCallback_IsPlainText()
    {
        var exec = Guid.NewGuid();
        var selected = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", exec, [Line("info", "a", seq: 1)]);
        var other = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId, RecipeName", exec, [Line("warn", "b", seq: 2)]);
        var graph = new FlowGraph([selected, other], [], false, false);

        var cut = Render(p => p.Add(x => x.Step, selected).Add(x => x.Graph, graph));
        cut.Find("[data-test=flow-step-tab-log]").Click();

        var hint = cut.Find("[data-test=flow-step-log-hint]");
        Assert.Contains("Write RecipeId, RecipeName", hint.TextContent);
        Assert.Null(hint.QuerySelector("button"));
    }

    [Fact]
    public void OutputAlert_SeeExceptionLink_SwitchesToTheLogTab()
    {
        var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(),
            [Line("error", "Rejecting recipe 42", seq: 1)], ExceptionDetail);
        var cut = Render(p => p.Add(x => x.Step, step));

        cut.Find("[data-test=flow-step-tab-output]").Click();
        Assert.Contains("Recipe 42 rejected", cut.Find("[data-test=flow-step-error]").TextContent);

        cut.Find("[data-test=flow-step-error-see-log]").Click();
        Assert.Contains("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
        Assert.NotNull(cut.Find("[data-test=flow-step-exception]"));
    }

    [Fact]
    public void RuntimeSourcedLine_ShowsItsSource_AndAnAttachedException()
    {
        var step = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId", Guid.NewGuid(),
        [
            Line("warn", "acked with quality Uncertain", source: "runtime", seq: 1,
                exceptionMessage: "Tag write was not confirmed", exceptionDetail: "System.TimeoutException: Tag write was not confirmed\n   at Dcl"),
        ]);
        var cut = Render(p => p.Add(x => x.Step, step));
        cut.Find("[data-test=flow-step-tab-log]").Click();

        var row = cut.Find("[data-test=flow-step-log-lines] tr[data-level=warn]");
        Assert.Contains("runtime", row.TextContent);
        Assert.Contains("Tag write was not confirmed", row.QuerySelector(".fsd-line-exc")!.TextContent);
        Assert.Contains("System.TimeoutException", row.QuerySelector("details pre")!.TextContent);
    }

    [Fact]
    public void SwitchingSteps_KeepsAStillValidTab()
    {
        var a = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid());
        var b = ScriptStep(FlowStepOutcome.Ok, "AckRecipe", Guid.NewGuid());

        var cut = Render(p => p.Add(x => x.Step, a));
        cut.Find("[data-test=flow-step-tab-timing]").Click();
        Assert.Contains("active", cut.Find("[data-test=flow-step-tab-timing]").ClassName);

        cut.Render(p => p.Add(x => x.Step, b));

        Assert.Contains("active", cut.Find("[data-test=flow-step-tab-timing]").ClassName);
        Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-input]").ClassName);
    }

    [Fact]
    public void RefreshingTheSameStep_KeepsTheTabAndTheLevelFilter()
    {
        // The page re-reads outcomes after a Live run and hands back a NEW FlowStep instance carrying
        // the same EventId plus whatever rows have landed since โ€” that must not disturb the panel.
        var eventId = Guid.NewGuid();
        var exec = Guid.NewGuid();
        FlowStep Refreshed(IReadOnlyList logs)
            => new(eventId, exec, null, FlowStepKind.ScriptRun, FlowLane.Site, "ProcessRecipeDownload",
                "sub", T0, 1731, FlowStepOutcome.Failed, "{}", "{}", "Recipe 42 rejected by Galaxy (code 7)",
                "site-a", "node-a", "DelmiaSim01", "ProcessRecipeDownload", null, null, null, ExceptionDetail, logs);

        var first = Refreshed([Line("info", "Recipe 42 requested", seq: 1), Line("warn", "ResultCode 7", seq: 2)]);
        var cut = Render(p => p.Add(x => x.Step, first));

        Assert.Equal("Log (2)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim());
        cut.Find("[data-test=flow-step-log-filter-warn]").Click();
        Assert.Single(cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]"));
        cut.Find("[data-test=flow-step-tab-timing]").Click();

        cut.Render(p => p.Add(x => x.Step, Refreshed(
        [
            Line("info", "Recipe 42 requested", seq: 1),
            Line("warn", "ResultCode 7", seq: 2),
            Line("error", "Rejecting recipe 42", seq: 3),
        ])));

        Assert.Contains("active", cut.Find("[data-test=flow-step-tab-timing]").ClassName);
        Assert.Equal("Log (3)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim());

        cut.Find("[data-test=flow-step-tab-log]").Click();
        Assert.Contains("btn-secondary", cut.Find("[data-test=flow-step-log-filter-warn]").ClassName);
        var rows = cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]");
        Assert.Equal(2, rows.Count);
        Assert.Equal("warn", rows[0].GetAttribute("data-level"));
        Assert.Equal("error", rows[1].GetAttribute("data-level"));
    }
}