fix(cli): AbCip subscribe serialises and guards console writes; Ctrl-C exits cleanly; arrays render
R07 U7: Driver.Cli.Common gains RunCancellableAsync (moved up from AbLegacy) and CreateChangeLineWriter (one lock-serialised line per change; a failing console write is logged and swallowed so it cannot fault the poll loop). AbCip probe/read/write print Cancelled. on Ctrl-C instead of a stack trace, and AbCip subscribe uses the shared writer. SnapshotFormatter renders arrays/lists as [a, b, c] instead of System.Int32[]. Refs #747 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+50
@@ -0,0 +1,50 @@
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U7: the AbCip probe/read/write commands had no <see cref="OperationCanceledException"/> arm, so a
|
||||
/// Ctrl-C printed a stack trace. A command cancelled by the operator now exits cleanly with <c>Cancelled.</c>.
|
||||
/// The gateway is TEST-NET (never answers), so nothing is sent to a real device.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CancellationHandlingTests
|
||||
{
|
||||
private const string Blackhole = "ab://192.0.2.1/1,0";
|
||||
|
||||
private static async Task<string> RunCancelledAsync(AbCipCommandBase command)
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
console.RequestCancellation();
|
||||
|
||||
await Should.NotThrowAsync(async () => await command.ExecuteAsync(console));
|
||||
return console.ReadErrorString();
|
||||
}
|
||||
|
||||
/// <summary>A cancelled read prints Cancelled. and does not throw.</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_read_prints_Cancelled()
|
||||
{
|
||||
(await RunCancelledAsync(new ReadCommand { Gateway = Blackhole, TagPath = "Motor01" }))
|
||||
.ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
/// <summary>A cancelled probe prints Cancelled. and does not throw.</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_probe_prints_Cancelled()
|
||||
{
|
||||
(await RunCancelledAsync(new ProbeCommand { Gateway = Blackhole, TagPath = "Motor01" }))
|
||||
.ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
/// <summary>A cancelled write prints Cancelled. and does not throw.</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_write_prints_Cancelled()
|
||||
{
|
||||
(await RunCancelledAsync(new WriteCommand { Gateway = Blackhole, TagPath = "Motor01", Value = "1" }))
|
||||
.ShouldContain("Cancelled.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U7, shared half: arrays render their elements (not <c>System.Int32[]</c>), Ctrl-C prints
|
||||
/// <c>Cancelled.</c>, and the subscribe change-line writer is serialised and never throws into the poll loop.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CliHelpersU7Tests
|
||||
{
|
||||
private sealed record Handle(string DiagnosticId) : ISubscriptionHandle;
|
||||
|
||||
private static DataChangeEventArgs Change(string reference, object? value) =>
|
||||
new(new Handle("h"), reference, new DataValueSnapshot(value, 0u, DateTime.UtcNow, DateTime.UtcNow));
|
||||
|
||||
/// <summary>An array value renders as its elements, each formatted like a scalar.</summary>
|
||||
[Fact]
|
||||
public void FormatValue_renders_array_elements()
|
||||
{
|
||||
SnapshotFormatter.FormatValue(new[] { 1, 2, 3 }).ShouldBe("[1, 2, 3]");
|
||||
SnapshotFormatter.FormatValue(new[] { 1.5f, -2f }).ShouldBe("[1.5, -2]");
|
||||
SnapshotFormatter.FormatValue(new[] { true, false }).ShouldBe("[true, false]");
|
||||
SnapshotFormatter.FormatValue(new[] { "a", "b" }).ShouldBe("[\"a\", \"b\"]");
|
||||
SnapshotFormatter.FormatValue(new List<short> { 7 }).ShouldBe("[7]");
|
||||
SnapshotFormatter.FormatValue(Array.Empty<int>()).ShouldBe("[]");
|
||||
}
|
||||
|
||||
/// <summary>A string is still a scalar, not a sequence of chars (positive control for the array arm).</summary>
|
||||
[Fact]
|
||||
public void FormatValue_keeps_a_string_scalar()
|
||||
{
|
||||
SnapshotFormatter.FormatValue("abc").ShouldBe("\"abc\"");
|
||||
}
|
||||
|
||||
/// <summary>Operator Ctrl-C prints Cancelled. instead of a stack trace.</summary>
|
||||
[Fact]
|
||||
public async Task RunCancellableAsync_operator_cancellation_prints_Cancelled()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await DriverCommandBase.RunCancellableAsync(console, cts.Token, () => Task.FromCanceled(cts.Token)));
|
||||
|
||||
console.ReadErrorString().ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
/// <summary>A cancellation the operator did not request still propagates.</summary>
|
||||
[Fact]
|
||||
public async Task RunCancellableAsync_unrequested_cancellation_propagates()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await DriverCommandBase.RunCancellableAsync(console, CancellationToken.None,
|
||||
() => throw new OperationCanceledException()));
|
||||
}
|
||||
|
||||
/// <summary>A change line carries the reference, the formatted value and the status.</summary>
|
||||
[Fact]
|
||||
public void ChangeLineWriter_writes_one_formatted_line_per_change()
|
||||
{
|
||||
var output = new StringWriter();
|
||||
var write = DriverCommandBase.CreateChangeLineWriter(output, new object());
|
||||
|
||||
write(this, Change("Tag:DInt", new[] { 4, 5 }));
|
||||
|
||||
var line = output.ToString().TrimEnd();
|
||||
line.ShouldContain("Tag:DInt = [4, 5] (0x00000000 (Good))");
|
||||
}
|
||||
|
||||
/// <summary>Concurrent poll-thread changes produce whole lines, never interleaved fragments.</summary>
|
||||
[Fact]
|
||||
public async Task ChangeLineWriter_serialises_concurrent_writes()
|
||||
{
|
||||
var output = new StringWriter(new StringBuilder());
|
||||
var write = DriverCommandBase.CreateChangeLineWriter(output, new object());
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(t => Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < 200; i++) write(this, Change($"T{t}", i));
|
||||
})));
|
||||
|
||||
var lines = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
lines.Length.ShouldBe(1600);
|
||||
lines.ShouldAllBe(l => l.StartsWith('[') && l.EndsWith("(Good))"));
|
||||
}
|
||||
|
||||
/// <summary>A failing console (closed pipe) is swallowed so it cannot fault the driver's poll loop.</summary>
|
||||
[Fact]
|
||||
public void ChangeLineWriter_swallows_a_console_write_failure()
|
||||
{
|
||||
var write = DriverCommandBase.CreateChangeLineWriter(new ThrowingWriter(), new object());
|
||||
|
||||
Should.NotThrow(() => write(this, Change("T", 1)));
|
||||
}
|
||||
|
||||
private sealed class ThrowingWriter : TextWriter
|
||||
{
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
public override void Write(char value) => throw new IOException("pipe closed");
|
||||
public override void WriteLine(string? value) => throw new IOException("pipe closed");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user