Files
lmxopcua/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/SnapshotFormatterTests.cs
Joseph Doherty a6ae4e22d1 fix(status-codes): correct BadDeviceFailure from 0x80550000 to 0x808B0000
Driver.Cli.Common-007 + Driver.Cli.Common-008 resolution.

Driver.Cli.Common-007 (High, Correctness):
  0x80550000 is the canonical OPC UA spec value for BadSecurityPolicyRejected,
  not BadDeviceFailure. The correct spec value for BadDeviceFailure is
  0x808B0000 (verified against OPC Foundation Opc.Ua.StatusCodes;
  corroborated locally by Driver.Galaxy.Runtime.StatusCodeMap and both
  Wonderware historian quality mappers which all hand-pin the correct
  value).

  The bug was duplicated across six driver modules:
    - FocasStatusMapper.BadDeviceFailure
    - AbCipStatusMapper.BadDeviceFailure
    - AbLegacyStatusMapper.BadDeviceFailure
    - TwinCATStatusMapper.BadDeviceFailure
    - ModbusDriver.StatusBadDeviceFailure
    - S7Driver.StatusBadDeviceFailure
  Plus the SnapshotFormatter shortlist that named 0x80550000 as
  BadDeviceFailure, and three downstream Modbus tests that asserted
  against the wrong value (so CI was blind).

  This commit fixes all six native-mapper constants, the formatter
  shortlist, and the three Modbus tests in one pass. Added a regression
  guard to FormatStatus_does_not_apply_pre_fix_wrong_names that pins
  0x80550000 never renders as BadDeviceFailure (mirroring the existing
  -001 wrong-name guards).

  Behavior change: OPC UA clients consuming the native drivers now see
  the canonical BadDeviceFailure (0x808B0000) on device-fault paths
  instead of the misnamed BadSecurityPolicyRejected (0x80550000). Wire-
  level status semantics now match operator-facing CLI labels.

Driver.Cli.Common-008 (Low, Testing):
  Deleted the redundant FormatStatus_names_native_driver_emitted_codes
  Theory — its five InlineData rows were already covered by the
  well-known Theory in the same commit (5a9c459), and used a weaker
  ShouldContain vs the well-known Theory's ShouldBe (exact match).

Verification:
  - Driver.Cli.Common.Tests: 43/43 pass (was 48 after the -008 deletion).
  - Driver.Modbus.Tests: 263/263 pass.
  - Driver.AbCip.Tests: 262/262.
  - Driver.AbLegacy.Tests: 157/157.
  - Driver.FOCAS.Tests: 178/178.
  - Driver.S7.Tests: 112/112.
  - Driver.TwinCAT.Tests: 131/131.
  Total: 1146 tests across the affected modules, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:14:28 -04:00

269 lines
11 KiB
C#

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(0x80020000u, "BadInternalError")]
[InlineData(0x80050000u, "BadCommunicationError")]
[InlineData(0x800A0000u, "BadTimeout")]
[InlineData(0x80310000u, "BadNoCommunication")]
[InlineData(0x80320000u, "BadWaitingForInitialData")]
[InlineData(0x80340000u, "BadNodeIdUnknown")]
[InlineData(0x80330000u, "BadNodeIdInvalid")]
[InlineData(0x803B0000u, "BadNotWritable")]
[InlineData(0x803C0000u, "BadOutOfRange")]
[InlineData(0x803D0000u, "BadNotSupported")]
// Driver.Cli.Common-007: corrected from 0x80550000 (which is actually
// BadSecurityPolicyRejected) to the canonical OPC UA spec value 0x808B0000.
[InlineData(0x808B0000u, "BadDeviceFailure")]
[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
// Driver.Cli.Common-007: 0x80550000 is BadSecurityPolicyRejected per spec, not
// BadDeviceFailure (which is 0x808B0000). The buggy shortlist + the six native-
// protocol mappers all had it wrong; this row pins it as a regression guard.
[InlineData(0x80550000u, "BadDeviceFailure")]
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: <null>");
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<ArgumentException>(() => 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<string>(),
Array.Empty<DataValueSnapshot>());
table.ShouldContain("TAG");
table.ShouldContain("VALUE");
table.ShouldContain("STATUS");
table.ShouldContain("SOURCE TIME");
table.ShouldContain("---");
}
}
[Trait("Category", "Unit")]
public sealed class DriverCommandBaseTests
{
/// <summary>
/// Minimal concrete subclass used only for testing the base class helpers.
/// </summary>
[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();
}
/// <summary>
/// Resets the global Serilog logger so tests do not bleed into each other.
/// </summary>
private static void DriverCommandBase_TestCommand_Teardown() =>
Serilog.Log.CloseAndFlush();
}