using CliFx.Infrastructure; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests; [Trait("Category", "Unit")] public sealed class SnapshotFormatterTests { private static readonly DateTime FixedTime = new(2026, 4, 21, 12, 34, 56, 789, DateTimeKind.Utc); [Fact] public void Format_includes_tag_value_status_and_both_timestamps() { var snap = new DataValueSnapshot(42, 0u, FixedTime, FixedTime); var output = SnapshotFormatter.Format("N7:0", snap); output.ShouldContain("Tag: N7:0"); output.ShouldContain("Value: 42"); output.ShouldContain("Status: 0x00000000 (Good)"); output.ShouldContain("Source Time: 2026-04-21T12:34:56.789Z"); output.ShouldContain("Server Time: 2026-04-21T12:34:56.789Z"); } [Theory] // Numeric codes are the canonical OPC Foundation Opc.Ua.StatusCodes values. [InlineData(0x00000000u, "Good")] [InlineData(0x80000000u, "Bad")] [InlineData(0x80050000u, "BadCommunicationError")] [InlineData(0x800A0000u, "BadTimeout")] [InlineData(0x80310000u, "BadNoCommunication")] [InlineData(0x80320000u, "BadWaitingForInitialData")] [InlineData(0x80340000u, "BadNodeIdUnknown")] [InlineData(0x80330000u, "BadNodeIdInvalid")] [InlineData(0x80740000u, "BadTypeMismatch")] [InlineData(0x40000000u, "Uncertain")] public void FormatStatus_names_well_known_status_codes(uint status, string expectedName) { SnapshotFormatter.FormatStatus(status).ShouldBe($"0x{status:X8} ({expectedName})"); } [Theory] // Regression for Driver.Cli.Common-001: these codes were previously mapped to the // wrong names. The hex values below are what the buggy shortlist used; they must // now either resolve to their *correct* spec name or fall through to bare hex. [InlineData(0x80060000u, "BadTimeout")] // was mislabelled BadTimeout [InlineData(0x80070000u, "BadNoCommunication")] // was mislabelled BadNoCommunication [InlineData(0x80080000u, "BadWaitingForInitialData")] // was mislabelled BadWaitingForInitialData [InlineData(0x80350000u, "BadNodeIdInvalid")] // was mislabelled BadNodeIdInvalid public void FormatStatus_does_not_apply_pre_fix_wrong_names(uint status, string wrongName) { SnapshotFormatter.FormatStatus(status).ShouldNotContain(wrongName); } [Fact] public void FormatStatus_unknown_codes_fall_back_to_severity_class() { // 0xDEADBEEF isn't in the named shortlist. Since -002 was fixed the severity // fallback (bit 31 = 1 → "Bad") applies, so operators always see a quality label. SnapshotFormatter.FormatStatus(0xDEADBEEFu).ShouldBe("0xDEADBEEF (Bad)"); } [Fact] public void FormatValue_renders_null_as_placeholder() { var snap = new DataValueSnapshot(null, 0x80050000u, null, FixedTime); var output = SnapshotFormatter.Format("Orphan", snap); output.ShouldContain("Value: "); output.ShouldContain("Source Time: -"); // null timestamp → dash } [Fact] public void FormatValue_formats_booleans_lowercase() { var snap = new DataValueSnapshot(true, 0u, FixedTime, FixedTime); SnapshotFormatter.Format("Coil", snap).ShouldContain("Value: true"); } [Fact] public void FormatValue_formats_floats_invariant_culture() { // Guards against non-invariant decimal separators (e.g. comma on PL locales) // that would break cross-platform log diffs. var snap = new DataValueSnapshot(3.14f, 0u, FixedTime, FixedTime); SnapshotFormatter.Format("F8:0", snap).ShouldContain("3.14"); } [Fact] public void FormatValue_quotes_strings() { var snap = new DataValueSnapshot("hello", 0u, FixedTime, FixedTime); SnapshotFormatter.Format("Msg", snap).ShouldContain("\"hello\""); } [Fact] public void FormatWrite_shows_status_with_tag_name() { var result = new WriteResult(0u); SnapshotFormatter.FormatWrite("Scratch", result) .ShouldBe("Write Scratch: 0x00000000 (Good)"); } [Fact] public void FormatTable_aligns_columns_and_includes_header_separator() { var names = new[] { "A", "LongerTag" }; var snaps = new[] { new DataValueSnapshot(1, 0u, FixedTime, FixedTime), new DataValueSnapshot(2, 0u, FixedTime, FixedTime), }; var table = SnapshotFormatter.FormatTable(names, snaps); table.ShouldContain("TAG"); table.ShouldContain("VALUE"); table.ShouldContain("STATUS"); table.ShouldContain("SOURCE TIME"); table.ShouldContain("---"); // separator row table.ShouldContain("LongerTag"); table.ShouldContain("0x00000000"); } [Fact] public void FormatTable_rejects_mismatched_lengths() { Should.Throw(() => SnapshotFormatter.FormatTable( new[] { "A", "B" }, new[] { new DataValueSnapshot(1, 0u, FixedTime, FixedTime) })); } [Fact] public void FormatTimestamp_normalises_local_kind_to_utc() { // Unspecified / Local times must land on UTC in the output — otherwise a CI box in // UTC+X would emit diffs against dev-laptop runs. var local = new DateTime(2026, 4, 21, 8, 0, 0, DateTimeKind.Local); var formatted = SnapshotFormatter.FormatTimestamp(local); formatted.ShouldEndWith("Z"); } // --- Driver.Cli.Common-002: sub-code bits in status codes --- [Theory] // Status codes with non-zero low-word flag bits must still resolve to the named // high-word class (Driver.Cli.Common-002). [InlineData(0x00000001u, "Good")] // Good + info bit [InlineData(0x80050001u, "BadCommunicationError")] // BadCommunicationError + sub-bit [InlineData(0x800A0010u, "BadTimeout")] // BadTimeout + sub-bits [InlineData(0x40000080u, "Uncertain")] // Uncertain + info bit public void FormatStatus_with_sub_code_bits_resolves_to_named_class(uint statusCode, string expectedName) { SnapshotFormatter.FormatStatus(statusCode).ShouldContain($"({expectedName})"); } [Theory] // Unknown sub-codes fall back to the severity class (Good / Uncertain / Bad). [InlineData(0x80990000u, "Bad")] // Unknown bad sub-code → "Bad" [InlineData(0x80990001u, "Bad")] // Unknown bad sub-code + flag bit → "Bad" [InlineData(0x40990000u, "Uncertain")] // Unknown uncertain sub-code → "Uncertain" [InlineData(0x00990000u, "Good")] // Unknown good sub-code → "Good" public void FormatStatus_unknown_sub_code_falls_back_to_severity_class(uint statusCode, string expectedSeverity) { SnapshotFormatter.FormatStatus(statusCode).ShouldContain($"({expectedSeverity})"); } // --- FormatTable empty-input --- [Fact] public void FormatTable_with_empty_input_returns_header_only() { // A batch read that returns zero tags must not throw — it should emit just the // header + separator rows (Driver.Cli.Common-004 / Driver.Cli.Common-005). var table = SnapshotFormatter.FormatTable( Array.Empty(), Array.Empty()); table.ShouldContain("TAG"); table.ShouldContain("VALUE"); table.ShouldContain("STATUS"); table.ShouldContain("SOURCE TIME"); table.ShouldContain("---"); } } [Trait("Category", "Unit")] public sealed class DriverCommandBaseTests { /// /// Minimal concrete subclass used only for testing the base class helpers. /// [CliFx.Attributes.Command("test-stub", Description = "Test stub — not a real command.")] private sealed class TestCommand : DriverCommandBase { public override TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(1); public override ValueTask ExecuteAsync(IConsole console) => default; // Expose protected methods for testing. public void InvokeConfigureLogging() => ConfigureLogging(); public static void InvokeFlushLogging() => FlushLogging(); } [Fact] public void ConfigureLogging_non_verbose_sets_warning_level() { var cmd = new TestCommand { Verbose = false }; cmd.InvokeConfigureLogging(); // At Warning level, Debug writes must not flow. Serilog.Log.Logger.IsEnabled(Serilog.Events.LogEventLevel.Debug).ShouldBeFalse(); Serilog.Log.Logger.IsEnabled(Serilog.Events.LogEventLevel.Warning).ShouldBeTrue(); DriverCommandBase_TestCommand_Teardown(); } [Fact] public void ConfigureLogging_verbose_sets_debug_level() { var cmd = new TestCommand { Verbose = true }; cmd.InvokeConfigureLogging(); Serilog.Log.Logger.IsEnabled(Serilog.Events.LogEventLevel.Debug).ShouldBeTrue(); DriverCommandBase_TestCommand_Teardown(); } [Fact] public void ConfigureLogging_is_idempotent_second_call_is_noop() { // First call sets verbose=false (Warning); second call with verbose=true must not // reconfigure — the guard makes it a no-op. var cmd = new TestCommand { Verbose = false }; cmd.InvokeConfigureLogging(); var loggerAfterFirst = Serilog.Log.Logger; // A new instance would normally apply its own Verbose=true setting, but here we // reuse the same instance so the guard field fires. cmd.InvokeConfigureLogging(); // no-op Serilog.Log.Logger.ShouldBeSameAs(loggerAfterFirst); DriverCommandBase_TestCommand_Teardown(); } [Fact] public void FlushLogging_does_not_throw() { // After CloseAndFlush the static logger is replaced with a silent logger; // verify the call itself does not throw. TestCommand.InvokeFlushLogging(); } /// /// Resets the global Serilog logger so tests do not bleed into each other. /// private static void DriverCommandBase_TestCommand_Teardown() => Serilog.Log.CloseAndFlush(); }