using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// /// hw2 review of the live-gate fix: the once-per-device "sum-read chunk failed" Warning exists to surface a /// sum-read path that fails on every poll (the gate's silent failure). A device that is merely unreachable must /// not consume it — the connect path reports that itself — or the later real defect is logged nowhere. /// [Trait("Category", "Unit")] public sealed class TwinCATSumChunkWarningTests { private const string Host = "ads://5.23.91.23.1.1:851"; private const string ChunkWarning = "an ADS sum-read chunk"; [Fact] public async Task A_connect_failure_does_not_burn_the_warning_and_a_later_real_chunk_failure_warns_once() { var connectFails = true; var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { ThrowOnConnect = connectFails, Exception = new InvalidOperationException("device down"), SumReadException = new InvalidOperationException("sum path broken"), }, }; var logger = new CapturingLogger(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], RawTags = TwinCATRawTags.Entries([ new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt), new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt), ]), Probe = new TwinCATProbeOptions { Enabled = false }, UseSumRead = true, SumReadMaxItems = 10, }, "drv-warn", factory, logger); await drv.InitializeAsync("{}", CancellationToken.None); // Unreachable: the connect attempt fails, then the backoff window rejects further attempts. for (var i = 0; i < 3; i++) (await drv.ReadAsync(["A", "B"], CancellationToken.None)) .ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.BadCommunicationError); logger.Warnings.Count(w => w.Contains(ChunkWarning)).ShouldBe(0); // Device comes back, but the sum path itself fails: wait out the 1 s connect backoff, then two polls. connectFails = false; var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (!factory.Clients.Any(c => c.IsConnected) && DateTime.UtcNow < deadline) { await drv.ReadAsync(["A", "B"], CancellationToken.None); await Task.Delay(50); } factory.Clients.Any(c => c.IsConnected).ShouldBeTrue("the device must reconnect for the second half to mean anything"); await drv.ReadAsync(["A", "B"], CancellationToken.None); await drv.ReadAsync(["A", "B"], CancellationToken.None); logger.Warnings.Count(w => w.Contains(ChunkWarning)).ShouldBe(1); } /// Source guard: the driver's only Beckhoff SumRead construction is inside /// CreateSumRead, so re-inlining a List-based construction in ReadGroupAsync goes red. [Fact] public void ReadGroupAsync_builds_its_sum_command_through_CreateSumRead_only() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) dir = dir.Parent; dir.ShouldNotBeNull("repo root not found"); var src = File.ReadAllText(Path.Combine(dir!.FullName, "src", "Drivers", "ZB.MOM.WW.OtOpcUa.Driver.TwinCAT", "AdsTwinCATClient.cs")); Regex.Matches(src, @"new\s+SumRead\s*\(").Count.ShouldBe(1); var create = src.IndexOf("internal static SumRead CreateSumRead(", StringComparison.Ordinal); create.ShouldBeGreaterThan(0); var ctor = Regex.Match(src, @"new\s+SumRead\s*\(").Index; ctor.ShouldBeGreaterThan(create); src.IndexOf("private const uint SymbolValueByHandle", StringComparison.Ordinal).ShouldBeGreaterThan(ctor); Regex.IsMatch(src, @"await\s+CreateSumRead\(_client").ShouldBeTrue(); } private sealed class CapturingLogger : ILogger { public List Warnings { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (logLevel == LogLevel.Warning) lock (Warnings) Warnings.Add(formatter(state, exception)); } } }