using Serilog.Events; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; /// /// Verifies converts Serilog /// instances into records and hands them to the /// . Covers level filtering, property extraction, /// fallback for missing identity props, and message template rendering. /// [Trait("Category", "Unit")] public sealed class ScriptLogTopicSinkTests { // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- private sealed class FakePublisher : IScriptLogPublisher { /// Gets the entries that have been published. public List Published { get; } = []; /// public void Publish(ScriptLogEntry entry) => Published.Add(entry); } /// Builds a with an optional set of string properties. private static LogEvent Evt( LogEventLevel level, string template, params (string Key, string Val)[] props) { var parser = new Serilog.Parsing.MessageTemplateParser(); var tmpl = parser.Parse(template); var logProps = props.Select(p => new LogEventProperty(p.Key, new ScalarValue(p.Val))); return new LogEvent(DateTimeOffset.UtcNow, level, exception: null, tmpl, logProps); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- /// /// An Information event with all identity props present → publisher receives /// exactly one entry with correctly mapped fields and null AlarmId. /// [Fact] public void Information_event_with_all_identity_props_is_published_correctly() { var publisher = new FakePublisher(); var sink = new ScriptLogTopicSink(publisher); var evt = Evt(LogEventLevel.Information, "hello", (ScriptLoggerFactory.ScriptIdProperty, "S1"), (ScriptLoggerFactory.VirtualTagIdProperty, "V1"), (ScriptLoggerFactory.EquipmentIdProperty, "EQ1")); sink.Emit(evt); publisher.Published.Count.ShouldBe(1); var entry = publisher.Published[0]; entry.ScriptId.ShouldBe("S1"); entry.Level.ShouldBe("Information"); entry.Message.ShouldBe("hello"); entry.VirtualTagId.ShouldBe("V1"); entry.EquipmentId.ShouldBe("EQ1"); entry.AlarmId.ShouldBeNull(); } /// /// An AlarmId property on the event is mapped to . /// [Fact] public void AlarmId_prop_is_mapped_to_entry_AlarmId() { var publisher = new FakePublisher(); var sink = new ScriptLogTopicSink(publisher); var evt = Evt(LogEventLevel.Information, "alarm fired", (ScriptLoggerFactory.ScriptIdProperty, "S2"), (ScriptLoggerFactory.AlarmIdProperty, "A1")); sink.Emit(evt); publisher.Published[0].AlarmId.ShouldBe("A1"); } /// /// When no identity properties are present all nullable fields are null and /// ScriptId falls back to "unknown". /// [Fact] public void Absent_identity_props_yield_null_fields_and_unknown_ScriptId() { var publisher = new FakePublisher(); var sink = new ScriptLogTopicSink(publisher); var evt = Evt(LogEventLevel.Warning, "bare message"); sink.Emit(evt); publisher.Published.Count.ShouldBe(1); var entry = publisher.Published[0]; entry.ScriptId.ShouldBe("unknown"); entry.VirtualTagId.ShouldBeNull(); entry.AlarmId.ShouldBeNull(); entry.EquipmentId.ShouldBeNull(); } /// /// When only a ScriptName property (no ScriptId) is present, /// falls back to the ScriptName value. /// [Fact] public void ScriptName_fallback_used_when_ScriptId_absent() { var publisher = new FakePublisher(); var sink = new ScriptLogTopicSink(publisher); var evt = Evt(LogEventLevel.Information, "using name fallback", (ScriptLoggerFactory.ScriptNameProperty, "NameFallback")); sink.Emit(evt); publisher.Published[0].ScriptId.ShouldBe("NameFallback"); } /// /// A Debug event is filtered by the default minimum level (Information) and /// the publisher receives nothing. /// [Fact] public void Debug_event_filtered_by_default_minimum_level() { var publisher = new FakePublisher(); var sink = new ScriptLogTopicSink(publisher); // default min = Information var evt = Evt(LogEventLevel.Debug, "debug noise", (ScriptLoggerFactory.ScriptIdProperty, "S3")); sink.Emit(evt); publisher.Published.ShouldBeEmpty(); } /// /// When the sink is constructed with min: Debug, a Debug event reaches /// the publisher with Level=="Debug". /// [Fact] public void Debug_event_published_when_sink_min_level_is_Debug() { var publisher = new FakePublisher(); var sink = new ScriptLogTopicSink(publisher, min: LogEventLevel.Debug); var evt = Evt(LogEventLevel.Debug, "debug detail", (ScriptLoggerFactory.ScriptIdProperty, "S4")); sink.Emit(evt); publisher.Published.Count.ShouldBe(1); publisher.Published[0].Level.ShouldBe("Debug"); } /// /// A templated message is fully rendered before being stored so the consumer /// sees a plain string rather than a raw template. /// [Fact] public void Templated_message_is_rendered_before_publishing() { var publisher = new FakePublisher(); var sink = new ScriptLogTopicSink(publisher); // Build the event manually to include a numeric property (can't be passed as string // in the helper since we need an integer ScalarValue for realistic rendering). var parser = new Serilog.Parsing.MessageTemplateParser(); var tmpl = parser.Parse("v={V}"); var props = new[] { new LogEventProperty("V", new ScalarValue(3)) }; var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Information, null, tmpl, props); sink.Emit(evt); publisher.Published[0].Message.ShouldBe("v=3"); } /// Passing a null publisher to the constructor throws . [Fact] public void Null_publisher_throws_ArgumentNullException() { Should.Throw(() => new ScriptLogTopicSink(null!)); } }