a92fb81f00
- ReadBucketAsync: the once-per-device "sum-read chunk failed" Warning fires only after a successful connect; a connect/backoff failure is the connect path's to report. TwinCATSumChunkWarningTests pins it (no warning across connect failures, exactly one across two later failing sum polls; red without the guard) plus a source guard that ReadGroupAsync builds its SumRead only via CreateSumRead. - Docs: diagnostics live-gated via the driver harness (/hosts not exercised); TC2 costs 8 reads on the first tick and after each reconnect, then <= 4; read counts attributed to the unit tests. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
100 lines
4.7 KiB
C#
100 lines
4.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Source guard: the driver's only Beckhoff <c>SumRead</c> construction is inside
|
|
/// <c>CreateSumRead</c>, so re-inlining a <c>List</c>-based construction in <c>ReadGroupAsync</c> goes red.</summary>
|
|
[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<TwinCATDriver>
|
|
{
|
|
public List<string> Warnings { get; } = [];
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
if (logLevel == LogLevel.Warning) lock (Warnings) Warnings.Add(formatter(state, exception));
|
|
}
|
|
}
|
|
}
|