using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// Flow Debugger step logs (design 2026-09-09 D6): the decorator installed over the script /// context's logger while a run is traced. It must forward everything the node would have logged /// anyway, capture Information and above as runtime rows — including levels the node's own /// minimum level suppresses, because they are trace data, not node log output — and never capture /// Debug/Trace chatter. /// public class TraceCapturingLoggerTests { /// One row the sink's emitter received. private sealed record Row(string Level, string Source, string Message, Exception? Exception, Guid? StepEventId); /// An inner logger that records what it was asked to write, at a configurable minimum level. private sealed class RecordingLogger(LogLevel minimum = LogLevel.Trace) : ILogger { public List<(LogLevel Level, string Message)> Entries { get; } = []; public List Scopes { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull { Scopes.Add(state); return null; } public bool IsEnabled(LogLevel logLevel) => logLevel >= minimum; public void Log(LogLevel level, EventId eventId, TState state, Exception? exception, Func formatter) => Entries.Add((level, formatter(state, exception))); } private static (TraceCapturingLogger Logger, RecordingLogger Inner, List Rows) Make(LogLevel innerMinimum = LogLevel.Trace) { var inner = new RecordingLogger(innerMinimum); var rows = new List(); var sink = new ScriptLogger( new RecordingLogger(), (level, source, message, exception, stepEventId) => rows.Add(new Row(level, source, message, exception, stepEventId)), maxLines: 200, instance: "CvdReactor01", script: "OnHeartbeat", executionId: Guid.NewGuid()); return (new TraceCapturingLogger(inner, sink), inner, rows); } [Fact] public void Debug_IsForwarded_ButNotCaptured() { var (logger, inner, rows) = Make(); logger.LogDebug("internal chatter"); Assert.Single(inner.Entries); Assert.Empty(rows); } [Fact] public void Warning_IsCapturedAsARuntimeRow_WithItsException() { var (logger, inner, rows) = Make(); var boom = new TimeoutException("no reply"); logger.LogWarning(boom, "tag write timed out after {Ms} ms", 5_000); Assert.Single(inner.Entries); var row = Assert.Single(rows); Assert.Equal("warn", row.Level); Assert.Equal("runtime", row.Source); Assert.Equal("tag write timed out after 5000 ms", row.Message); Assert.Same(boom, row.Exception); } [Fact] public void ErrorAndCritical_AreBothCapturedAsError() { var (logger, _, rows) = Make(); logger.LogError("bad"); logger.LogCritical("worse"); Assert.Equal(["error", "error"], rows.Select(r => r.Level)); } [Fact] public void InformationIsEnabled_EvenWhenTheInnerLoggerSaysNo() { // A node whose Serilog minimum is Warning must still contribute Information lines to a trace. var (logger, inner, rows) = Make(innerMinimum: LogLevel.Warning); Assert.False(inner.IsEnabled(LogLevel.Information)); Assert.True(logger.IsEnabled(LogLevel.Information)); logger.LogInformation("captured anyway"); Assert.Empty(inner.Entries); var row = Assert.Single(rows); Assert.Equal("info", row.Level); Assert.Equal("captured anyway", row.Message); } [Fact] public void BelowInformation_TheInnerLoggerDecidesEnablement() { var (logger, _, _) = Make(innerMinimum: LogLevel.Warning); Assert.False(logger.IsEnabled(LogLevel.Debug)); } [Fact] public void ADisabledInnerLogger_IsNotWrittenTo_ButTheRowIsStillCaptured() { var (logger, inner, rows) = Make(innerMinimum: LogLevel.Critical); logger.LogWarning("suppressed on the node"); Assert.Empty(inner.Entries); Assert.Single(rows); } [Fact] public void CapturedLines_CarryTheAmbientStepId() { var (logger, _, rows) = Make(); var stepId = Guid.NewGuid(); using (TraceStepScope.Enter(stepId)) { logger.LogInformation("inside the helper"); } Assert.Equal(stepId, Assert.Single(rows).StepEventId); } [Fact] public void Ctor_RejectsAnAlreadyDecoratedLogger() { // Wrapping twice would forward each script line through two capture stages and emit two // runtime rows per line — and would burn the run's line cap at double rate. var (already, _, _) = Make(); var sink = new ScriptLogger(new RecordingLogger(), emit: null, maxLines: 200, "i", "s", null); var ex = Assert.Throws(() => new TraceCapturingLogger(already, sink)); Assert.Equal("inner", ex.ParamName); } [Fact] public void Ctor_RequiresBothCollaborators() { var sink = new ScriptLogger(new RecordingLogger(), emit: null, maxLines: 200, "i", "s", null); Assert.Throws(() => new TraceCapturingLogger(null!, sink)); Assert.Throws(() => new TraceCapturingLogger(new RecordingLogger(), null!)); } [Fact] public void AThrowingFormatter_IsSwallowed_AndDropsOnlyThatLine() { // Review M3. Most of what this decorator captures is the runtime's own best-effort // "audit write failed" warnings, logged from INSIDE a catch block: a formatter exception // escaping here would replace the original failure with a formatting one on a path whose // contract is that it never throws. The inner logger is disabled for this level so the // decorator's OWN formatter call is the only one under test. var (logger, inner, rows) = Make(innerMinimum: LogLevel.Critical); logger.Log(LogLevel.Warning, new EventId(0), "state", null, (_, _) => throw new InvalidOperationException("formatter broke")); Assert.Empty(rows); Assert.Empty(inner.Entries); // …and the decorator keeps working for the next line. logger.LogWarning("still alive"); Assert.Equal("still alive", Assert.Single(rows).Message); } [Fact] public void BeginScope_DelegatesToTheInnerLogger() { var (logger, inner, _) = Make(); logger.BeginScope("state"); Assert.Equal(["state"], inner.Scopes); } }