From 032598607ea988122c176c6900c19934025f93d6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 9 Sep 2026 12:38:35 -0400 Subject: [PATCH 1/2] feat(central-ui): Flow Debugger status strip log counts + inline-truncation notes; detail cross-step selection --- .../Pages/Deployment/FlowDebugger.razor | 16 +- .../Pages/Deployment/FlowDebugger.razor.cs | 45 ++++- .../Services/FlowDebugService.cs | 4 +- .../Services/IFlowDebugService.cs | 9 +- .../Deployment/FlowDebuggerPageTests.cs | 154 ++++++++++++++++++ .../Services/FlowDebugServiceTests.cs | 30 +++- 6 files changed, 250 insertions(+), 8 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor index 1dfb1a66..a3098293 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor @@ -166,6 +166,12 @@ Steps@_graph.Steps.Count Boundaries@_graph.Edges.Count(e => e.CrossesBoundary) Writes@_graph.Steps.Count(s => s.Kind == FlowStepKind.AttributeWrite) + @* Step logs (design 2026-09-09 D5): the run's line counts by level — a Log.Error line is + NOT a failed step, so it never moves the state chip. *@ + @if (LogCounts is { Total: > 0 }) + { + Log@LogSummary + } @if (PendingCount > 0) { Pending@PendingCount @@ -191,6 +197,14 @@ { event list truncated } + @if (_graph?.LogsTruncated == true) + { + log lines truncated inline — Replay is complete + } + @if (_graph?.UnattachedLogLines > 0) + { + @_graph.UnattachedLogLines log line(s) could not be placed on a step + }
@@ -284,7 +298,7 @@ SiteDbId="@(_entry == EntryKind.Script && _selectedSiteId > 0 ? _selectedSiteId : null)" InstanceDbId="@(_entry == EntryKind.Script && _selectedInstanceId > 0 ? _selectedInstanceId : null)" OnRetry="RetryCallback" OnDiscard="DiscardCallback" - ActionInProgress="_actionInProgress" /> + ActionInProgress="_actionInProgress" OnSelectStep="OnSelectStep" />
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs index 0c9608b9..2e48da63 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs @@ -183,6 +183,44 @@ public partial class FlowDebugger : ComponentBase, IDisposable private int PendingCount => _graph?.Steps.Count(s => s.Outcome == FlowStepOutcome.Pending) ?? 0; + /// + /// Step logs (design 2026-09-09 D5): the run's log lines summed by level over every step that + /// carries any. error and warn are counted by name (ordinal, case-insensitive — + /// the level rides the audit row as free text); everything else, debug included, is + /// "info", so the three counts always add up to Total. Lines the builder could not place + /// on a step are NOT here — they are reported separately as + /// . + /// + internal static (int Errors, int Warnings, int Info, int Total) CountLogs(FlowGraph? graph) + { + if (graph is null) + return (0, 0, 0, 0); + + var errors = 0; + var warnings = 0; + var info = 0; + foreach (var line in graph.Steps.SelectMany(s => s.Logs ?? [])) + { + if (string.Equals(line.Level, "error", StringComparison.OrdinalIgnoreCase)) errors++; + else if (string.Equals(line.Level, "warn", StringComparison.OrdinalIgnoreCase)) warnings++; + else info++; + } + + return (errors, warnings, info, errors + warnings + info); + } + + private (int Errors, int Warnings, int Info, int Total) LogCounts => CountLogs(_graph); + + /// Status-strip text for . + private string LogSummary + { + get + { + var counts = LogCounts; + return $"{counts.Errors} errors · {counts.Warnings} warnings · {counts.Info} info"; + } + } + /// Pending outbound work, or an acted-on node that is still Parked, keeps the refresh loop alive. private bool ShouldKeepPolling => PendingCount > 0 @@ -647,8 +685,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable var selectedId = _selected?.EventId; var merged = MergeEvents(_raw.Events, outcome.Raw?.Events); var truncated = _raw.Truncated || outcome.Raw?.Truncated == true; - _raw = _raw with { Events = merged, Truncated = truncated }; - _graph = FlowGraphBuilder.Build(merged, truncated); + // Truncation is sticky across a refresh, exactly as the event-list flag is: the Live run's + // inline copy stays the incomplete one whatever the re-read brought back (D7). + var logsTruncated = _raw.LogsTruncated || outcome.Raw?.LogsTruncated == true; + _raw = _raw with { Events = merged, Truncated = truncated, LogsTruncated = logsTruncated }; + _graph = FlowGraphBuilder.Build(merged, truncated, logsTruncated); _selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs index 4d937cca..a1fef15f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs @@ -537,8 +537,8 @@ public sealed class FlowDebugService : IFlowDebugService 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); + var graph = FlowGraphBuilder.Build(result.Events ?? [], result.Truncated, result.LogsTruncated); + return new FlowDebugOutcome(graph, result, null, null, result.LogsTruncated); } case ManagementUnauthorized unauthorized: return FlowDebugOutcome.Fail(unauthorized.Message, "UNAUTHORIZED"); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs index 238bba12..eb3d33b3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs @@ -15,7 +15,14 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// 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) +/// +/// Step logs (design 2026-09-09 D7): true when the inline trace collector dropped low-priority +/// ScriptLog rows to protect room for the step rows — the run's lines are incomplete inline, +/// while Replay (which reads the never-truncated audit store) is complete. Mirrors +/// so the page can note it without unwrapping the raw result. +/// +public sealed record FlowDebugOutcome( + FlowGraph? Graph, FlowDebugRunResult? Raw, string? Error, string? ErrorCode, bool LogsTruncated = false) { /// True when a graph was built (the run may still have finished incomplete — see ). public bool Success => Graph != null && Error == null; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs index 6bde8719..ef8bb157 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs @@ -903,4 +903,158 @@ public class FlowDebuggerPageTests : BunitContext Assert.Equal(string.Empty, cut.Find("[data-test='flow-instance-input']").GetAttribute("value")); Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList()); } + + // ---- Step logs (plan 2026-09-09 D5/D7, Task 13): status-strip counts, truncation notes, cross-step hint ---- + + /// One ScriptTrace.ScriptLog row — a line the builder folds onto a step, never a node of its own. + private static AuditEvent LogLine(Guid execution, string level, int ms, Guid? stepEventId = null, string message = "hello") => + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, + occurredAtUtc: DateTime.UtcNow.AddMilliseconds(ms), actor: "Process", target: "Process", sourceNode: "node-a", + executionId: execution, sourceSiteId: "site-a", sourceInstanceId: "Sim01", requestSummary: message, + extra: new ScriptTraceExtra { LogLevel = level, LogSource = "script", StepEventId = stepEventId }.ToJson()); + + /// + /// The flow (inbound → route → site run) whose site run carries the asked-for + /// log lines, optionally with lines belonging to an execution with no steps + /// (the builder drops and counts those) and the inline log-truncation flag set. + /// + private static FlowDebugOutcome LoggedOutcome(int errors = 0, int warnings = 0, int infos = 0, + bool logsTruncated = false, int unattached = 0) + { + var root = Guid.NewGuid(); + var child = Guid.NewGuid(); + var t0 = DateTime.UtcNow; + var events = new List + { + ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, + occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, + requestSummary: "{}", responseSummary: "{}"), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90, + extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root, + sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80), + }; + + var ms = 3; + for (var i = 0; i < errors; i++) events.Add(LogLine(child, "error", ms++)); + for (var i = 0; i < warnings; i++) events.Add(LogLine(child, "warn", ms++)); + for (var i = 0; i < infos; i++) events.Add(LogLine(child, "info", ms++)); + for (var i = 0; i < unattached; i++) events.Add(LogLine(Guid.NewGuid(), "info", ms++)); + + var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1), logsTruncated); + return new FlowDebugOutcome(FlowGraphBuilder.Build(events, false, logsTruncated), raw, null, null, logsTruncated); + } + + /// Picks the method, runs it against and waits for the status strip. + private IRenderedComponent RunWith(FlowDebugOutcome outcome) + { + _flow.RunMethodAsync(4, Arg.Any(), Arg.Any()).Returns(outcome); + var cut = Render(); + cut.Find("select[data-test='flow-method-select']").Change("4"); + cut.WaitForElement("input[data-test='flow-input-MachineCode']"); + cut.Find("button[data-test='flow-run']").Click(); + cut.WaitForElement("[data-test='flow-step-count']"); + return cut; + } + + [Fact] + public void StatusStrip_ShowsLogCountsByLevel() + { + RegisterServices(); + + var cut = RunWith(LoggedOutcome(errors: 1, warnings: 2, infos: 3)); + + Assert.Equal("1 errors · 2 warnings · 3 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); + } + + [Fact] + public void StatusStrip_OmitsLogCounts_WhenTheRunLoggedNothing() + { + RegisterServices(); + + var cut = RunWith(LoggedOutcome()); + + Assert.Empty(cut.FindAll("[data-test='flow-log-counts']")); + Assert.Empty(cut.FindAll("[data-test='flow-logs-truncated']")); + Assert.Empty(cut.FindAll("[data-test='flow-unattached-logs']")); + } + + [Fact] + public void StatusStrip_ShowsTheInlineLogTruncationBadge() + { + // D7: the inline copy dropped low-priority rows; the audit store — and so Replay — is complete. + RegisterServices(); + + var cut = RunWith(LoggedOutcome(infos: 1, logsTruncated: true)); + + Assert.Contains("Replay is complete", cut.Find("[data-test='flow-logs-truncated']").TextContent); + } + + [Fact] + public void StatusStrip_NotesLinesThatCouldNotBePlacedOnAStep() + { + RegisterServices(); + + var cut = RunWith(LoggedOutcome(infos: 1, unattached: 2)); + + Assert.Contains("2 log line(s) could not be placed", cut.Find("[data-test='flow-unattached-logs']").TextContent); + // The dropped lines are not counted on the strip — it counts only what landed on a step. + Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); + } + + [Fact] + public void StateChip_StaysCompleted_WhenTheOnlyErrorsAreLogLines() + { + // A Log.Error line is not a failed step: the run state is decided by step outcomes alone. + RegisterServices(); + + var cut = RunWith(LoggedOutcome(errors: 2)); + + Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()); + Assert.Equal("2 errors · 0 warnings · 0 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); + } + + [Fact] + public void StepDetail_LogHint_SelectsTheSiblingStepCarryingTheOtherLines() + { + RegisterServices(); + var root = Guid.NewGuid(); + var child = Guid.NewGuid(); + var writeId = Guid.NewGuid(); + var t0 = DateTime.UtcNow; + var events = new List + { + ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, + occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, + requestSummary: "{}", responseSummary: "{}"), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90, + extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root, + sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered, + eventId: writeId, occurredAtUtc: t0.AddMilliseconds(3), target: "Sim01.Flag", executionId: child, + sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 2, + extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("Sim01.Flag", "true", true, null)] }.ToJson()), + LogLine(child, "info", 4), // → the ScriptRun anchor + LogLine(child, "warn", 5, stepEventId: writeId), // → the write step + }; + var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1)); + var cut = RunWith(new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null)); + + cut.Find("g[data-test='flow-node'][data-kind='ScriptRun']").Click(); + cut.Find("[data-test='flow-step-tab-log']").Click(); + var before = cut.Find("[data-test='flow-step-title']").TextContent.Trim(); + var hint = cut.Find("[data-test='flow-step-log-hint'] button"); + var otherTitle = hint.TextContent.Trim(); + + hint.Click(); + + // The page owns the selection: clicking the hint moves the detail panel to the sibling step. + Assert.NotEqual(before, otherTitle); + Assert.Equal(otherTitle, cut.Find("[data-test='flow-step-title']").TextContent.Trim()); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs index 21fa0eb6..a3bb918b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs @@ -253,7 +253,7 @@ public sealed class FlowDebugServiceTests : IDisposable Assert.IsType(Assert.Single(_siteCallRequests)); } - private static string SuccessPayload(int events, bool completed = true, bool truncated = false) + private static string SuccessPayload(int events, bool completed = true, bool truncated = false, bool logsTruncated = false) { var root = Guid.NewGuid(); var child = Guid.NewGuid(); @@ -273,10 +273,36 @@ public sealed class FlowDebugServiceTests : IDisposable 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)); + var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1), logsTruncated); return JsonSerializer.Serialize(result, ServerJson); } + [Fact] + public async Task RunMethod_LogsTruncated_RidesOntoTheOutcomeAndTheGraph() + { + // Step logs D7: the run result's inline log-truncation flag is the page's only way to + // know the lines it holds are incomplete — carry it onto the outcome AND into the graph. + var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3, logsTruncated: true))); + + var outcome = await svc.RunMethodAsync(4, null); + + Assert.True(outcome.Success); + Assert.True(outcome.LogsTruncated); + Assert.True(outcome.Graph!.LogsTruncated); + Assert.True(outcome.Raw!.LogsTruncated); + } + + [Fact] + public async Task RunMethod_NoLogTruncation_LeavesTheFlagFalse() + { + var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3))); + + var outcome = await svc.RunMethodAsync(4, null); + + Assert.False(outcome.LogsTruncated); + Assert.False(outcome.Graph!.LogsTruncated); + } + [Fact] public async Task RunMethod_HolderNull_ReportsServiceUnavailable() { From 25c6e9ca8a1521b206d7843f954e1da56992a1d6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 9 Sep 2026 12:48:00 -0400 Subject: [PATCH 2/2] fix(central-ui): LogsTruncated clears once a refresh merges in more log rows; badge reworded (Task 13 review) --- .../Pages/Deployment/FlowDebugger.razor | 2 +- .../Pages/Deployment/FlowDebugger.razor.cs | 14 ++- .../Deployment/FlowDebuggerPageTests.cs | 105 +++++++++++++++++- 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor index a3098293..c3fa2338 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor @@ -199,7 +199,7 @@ } @if (_graph?.LogsTruncated == true) { - log lines truncated inline — Replay is complete + inline log lines capped — refresh outcomes pulls the rest } @if (_graph?.UnattachedLogLines > 0) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs index 2e48da63..6d2369f6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs @@ -685,14 +685,22 @@ public partial class FlowDebugger : ComponentBase, IDisposable var selectedId = _selected?.EventId; var merged = MergeEvents(_raw.Events, outcome.Raw?.Events); var truncated = _raw.Truncated || outcome.Raw?.Truncated == true; - // Truncation is sticky across a refresh, exactly as the event-list flag is: the Live run's - // inline copy stays the incomplete one whatever the re-read brought back (D7). - var logsTruncated = _raw.LogsTruncated || outcome.Raw?.LogsTruncated == true; + // D7: the inline copy's cap is only worth reporting while the page still holds that + // incomplete copy. A refresh MERGES rows, so the moment it brings back even one ScriptLog + // row the run did not already have, the page is no longer short of lines and the flag + // clears. Until then it stays: the site's rows reach central's audit store by telemetry + // 5-30 s later, so a refresh that adds nothing proves nothing. + var logsTruncated = (_raw.LogsTruncated || outcome.Raw?.LogsTruncated == true) + && CountLogRows(merged) <= CountLogRows(_raw.Events); _raw = _raw with { Events = merged, Truncated = truncated, LogsTruncated = logsTruncated }; _graph = FlowGraphBuilder.Build(merged, truncated, logsTruncated); _selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected; } + /// Number of ScriptTrace.ScriptLog rows in — the log lines a refresh can add to. + private static int CountLogRows(IReadOnlyList? events) + => events?.Count(FlowStepProjector.IsLogLine) ?? 0; + /// Union of two audit-row lists by ; a row present in both takes the fresh copy. internal static IReadOnlyList MergeEvents(IReadOnlyList? known, IReadOnlyList? fresh) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs index ef8bb157..c5540f17 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs @@ -989,7 +989,7 @@ public class FlowDebuggerPageTests : BunitContext var cut = RunWith(LoggedOutcome(infos: 1, logsTruncated: true)); - Assert.Contains("Replay is complete", cut.Find("[data-test='flow-logs-truncated']").TextContent); + Assert.Contains("inline log lines capped", cut.Find("[data-test='flow-logs-truncated']").TextContent); } [Fact] @@ -1053,8 +1053,109 @@ public class FlowDebuggerPageTests : BunitContext hint.Click(); - // The page owns the selection: clicking the hint moves the detail panel to the sibling step. + // The page owns the selection: clicking the hint moves the detail panel AND the graph's + // highlight to the sibling step. Assert.NotEqual(before, otherTitle); Assert.Equal(otherTitle, cut.Find("[data-test='flow-step-title']").TextContent.Trim()); + var selected = cut.Find("[data-test='flow-node'].selected"); + Assert.Equal("e:" + writeId.ToString("N"), selected.GetAttribute("data-key")); + } + + // ---- D7: the inline cap flag is only true while the page is still short of lines -------------- + + /// + /// A Live run that reported inline log truncation, plus the Replay a refresh reads back — sharing the + /// run's own event objects (stable ids, as real audit rows are) and, when asked, one more log row than + /// the run held. + /// + private static (FlowDebugOutcome Run, FlowDebugOutcome Refreshed, Guid Root) TruncatedThenRefreshed(bool refreshAddsALine) + { + var root = Guid.NewGuid(); + var child = Guid.NewGuid(); + var t0 = DateTime.UtcNow.AddSeconds(-5); + var events = new List + { + ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, + occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, + requestSummary: "{}", responseSummary: "{}"), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90, + extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()), + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root, + sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80), + LogLine(child, "info", 3), + }; + var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1), true); + var run = new FlowDebugOutcome(FlowGraphBuilder.Build(events, false, true), raw, null, null, true); + + // The audit store is never truncated, so the re-read's own flag is false whatever it holds. + var reread = new List(events); + if (refreshAddsALine) + reread.Add(LogLine(child, "warn", 4)); + var rereadRaw = new FlowDebugRunResult(root, true, null, null, reread, false, t0, t0.AddSeconds(1)); + var refreshed = new FlowDebugOutcome(FlowGraphBuilder.Build(reread), rereadRaw, null, null); + return (run, refreshed, root); + } + + [Fact] + public void RefreshOutcomes_ClearsTheInlineCapBadge_OnceItMergesInMoreLogRows() + { + RegisterServices(); + var (run, refreshed, root) = TruncatedThenRefreshed(refreshAddsALine: true); + _flow.ReplayAsync(root, Arg.Any()).Returns(refreshed); + var cut = RunWith(run); + cut.Instance.Delay = (_, _) => Task.CompletedTask; + Assert.NotNull(cut.Find("[data-test='flow-logs-truncated']")); + + cut.Find("button[data-test='flow-refresh']").Click(); + + cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='flow-logs-truncated']"))); + Assert.Equal("0 errors · 1 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); + } + + [Fact] + public void RefreshOutcomes_KeepsTheInlineCapBadge_WhenTheReReadBringsBackNoNewLines() + { + // The site's rows reach central by telemetry seconds later — a refresh that adds nothing + // has not proven the page holds every line. + RegisterServices(); + var (run, refreshed, root) = TruncatedThenRefreshed(refreshAddsALine: false); + _flow.ReplayAsync(root, Arg.Any()).Returns(refreshed); + var cut = RunWith(run); + cut.Instance.Delay = (_, _) => Task.CompletedTask; + + cut.Find("button[data-test='flow-refresh']").Click(); + + cut.WaitForAssertion(() => _flow.Received(1).ReplayAsync(root, Arg.Any())); + Assert.NotNull(cut.Find("[data-test='flow-logs-truncated']")); + Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim()); + } + + // ---- CountLogs: the level buckets, straight --------------------------------------------------- + + [Fact] + public void CountLogs_BucketsByLevel_CaseInsensitively_WithEverythingElseAsInfo() + { + var execution = Guid.NewGuid(); + var t0 = DateTime.UtcNow; + var graph = FlowGraphBuilder.Build([ + ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0, target: "Process", executionId: execution, + sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80), + LogLine(execution, "ERROR", 1), + LogLine(execution, "Warn", 2), + LogLine(execution, "info", 3), + LogLine(execution, "debug", 4), + LogLine(execution, "trace", 5), // an unknown level is still a line — it lands in info + ]); + + Assert.Equal((1, 1, 3, 5), FlowDebuggerPage.CountLogs(graph)); + } + + [Fact] + public void CountLogs_NullGraph_IsAllZeroes() + { + Assert.Equal((0, 0, 0, 0), FlowDebuggerPage.CountLogs(null)); } }