fix(cli): historyupdate exits non-zero unless every item is Good, and never prints a blank status
A Good service result with missing or too few per-item results, a missing result entry, or an Uncertain item now fails the command; a status code the SDK has no name for prints as hex. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -159,32 +159,54 @@ public class HistoryUpdateCommand : CommandBase
|
||||
await console.Output.WriteLineAsync($"HistoryUpdate {Mode.Trim().ToLowerInvariant()} on {NodeId}");
|
||||
await console.Output.WriteLineAsync($"{"Index",-6} {"Time",-35} {"Status"}");
|
||||
|
||||
var anyBad = false;
|
||||
// Exit non-zero unless EVERY expected result came back Good: a Bad or Uncertain item, a Bad result or
|
||||
// service status, or a Good service result that is missing results (the server answered fewer items
|
||||
// than were sent) all fail the command.
|
||||
var failed = false;
|
||||
var expected = ExpectedOperationCount(details);
|
||||
var ops = outcome.OperationResults.Count > 0 ? outcome.OperationResults[0] : [];
|
||||
if (ops.Count > 0)
|
||||
if (expected > 0)
|
||||
{
|
||||
for (var i = 0; i < ops.Count; i++)
|
||||
for (var i = 0; i < Math.Max(expected, ops.Count); i++)
|
||||
{
|
||||
var time = i < times.Count ? times[i] : "-";
|
||||
anyBad |= StatusCode.IsBad(ops[i]);
|
||||
await console.Output.WriteLineAsync($"{i,-6} {time,-35} {StatusCodes.GetBrowseName(ops[i].Code)}");
|
||||
string status;
|
||||
if (i < ops.Count)
|
||||
{
|
||||
failed |= !StatusCode.IsGood(ops[i]);
|
||||
status = StatusName(ops[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
failed = true;
|
||||
status = "(no result returned)";
|
||||
}
|
||||
await console.Output.WriteLineAsync($"{i,-6} {time,-35} {status}");
|
||||
}
|
||||
}
|
||||
else if (outcome.ResultStatus.Count > 0)
|
||||
{
|
||||
// delete-raw reports a single result-level status for the whole range.
|
||||
anyBad |= StatusCode.IsBad(outcome.ResultStatus[0]);
|
||||
await console.Output.WriteLineAsync(
|
||||
$"{0,-6} {(times.Count > 0 ? times[0] : "-"),-35} {StatusCodes.GetBrowseName(outcome.ResultStatus[0].Code)}");
|
||||
$"{0,-6} {(times.Count > 0 ? times[0] : "-"),-35} {StatusName(outcome.ResultStatus[0])}");
|
||||
}
|
||||
|
||||
var overall = StatusCodes.GetBrowseName(outcome.ServiceResult.Code);
|
||||
await console.Output.WriteLineAsync($"Service result: {overall}");
|
||||
anyBad |= StatusCode.IsBad(outcome.ServiceResult);
|
||||
if (outcome.ResultStatus.Count > 0 && StatusCode.IsBad(outcome.ResultStatus[0])) anyBad = true;
|
||||
if (outcome.ResultStatus.Count == 0)
|
||||
{
|
||||
failed = true;
|
||||
await console.Output.WriteLineAsync("No history-update result was returned for the node.");
|
||||
}
|
||||
else
|
||||
{
|
||||
failed |= !StatusCode.IsGood(outcome.ResultStatus[0]);
|
||||
}
|
||||
|
||||
if (anyBad)
|
||||
throw new CommandException("HistoryUpdate reported one or more Bad results.");
|
||||
await console.Output.WriteLineAsync($"Service result: {StatusName(outcome.ServiceResult)}");
|
||||
failed |= !StatusCode.IsGood(outcome.ServiceResult);
|
||||
|
||||
if (failed)
|
||||
throw new CommandException(
|
||||
"HistoryUpdate did not report Good for every item (a Bad or Uncertain result, or a missing result).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -196,6 +218,27 @@ public class HistoryUpdateCommand : CommandBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How many per-item operation results the server owes for <paramref name="details"/> (0 for delete-raw).</summary>
|
||||
private static int ExpectedOperationCount(HistoryUpdateDetails details) => details switch
|
||||
{
|
||||
UpdateDataDetails u => u.UpdateValues.Count,
|
||||
DeleteAtTimeDetails d => d.ReqTimes.Count,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The SDK's symbolic name for <paramref name="code"/>, or its hex value when the SDK has none — a status is
|
||||
/// never printed blank.
|
||||
/// </summary>
|
||||
/// <param name="code">The status code to describe.</param>
|
||||
/// <returns>The status name, or <c>0xXXXXXXXX</c>.</returns>
|
||||
private static string StatusName(StatusCode code)
|
||||
{
|
||||
var name = StatusCodes.GetBrowseName(code.Code);
|
||||
if (string.IsNullOrEmpty(name)) name = StatusCodes.GetBrowseName(code.Code & 0xFFFF0000u);
|
||||
return string.IsNullOrEmpty(name) ? $"0x{code.Code:X8}" : name;
|
||||
}
|
||||
|
||||
private static List<string> DescribeTimes(HistoryUpdateDetails details)
|
||||
{
|
||||
return details switch
|
||||
|
||||
@@ -130,4 +130,79 @@ public class HistoryUpdateCommandTests
|
||||
await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
|
||||
TestConsoleHelper.GetOutput(console).ShouldContain("BadEntryExists");
|
||||
}
|
||||
|
||||
private static readonly string[] TwoTimes = ["2026-01-15T08:00:00Z", "2026-01-15T09:00:00Z"];
|
||||
|
||||
/// <summary>A Good service result that carries NO per-item results must not exit 0.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_GoodServiceResult_WithMissingItemResults_Throws()
|
||||
{
|
||||
var svc = new FakeOpcUaClientService
|
||||
{
|
||||
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good], [new List<StatusCode>()])
|
||||
};
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<CommandException>(async () =>
|
||||
await Make(svc, values: ["1", "2"], times: TwoTimes).ExecuteAsync(console));
|
||||
TestConsoleHelper.GetOutput(console).ShouldContain("(no result returned)");
|
||||
}
|
||||
|
||||
/// <summary>Fewer per-item results than items sent must not exit 0, even when every returned one is Good.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_TooFewItemResults_Throws()
|
||||
{
|
||||
var svc = new FakeOpcUaClientService
|
||||
{
|
||||
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
||||
[new List<StatusCode> { StatusCodes.GoodEntryInserted }])
|
||||
};
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<CommandException>(async () =>
|
||||
await Make(svc, values: ["1", "2"], times: TwoTimes).ExecuteAsync(console));
|
||||
}
|
||||
|
||||
/// <summary>A Good service result with no history-update result at all must not exit 0.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_NoResultEntry_Throws()
|
||||
{
|
||||
var svc = new FakeOpcUaClientService
|
||||
{
|
||||
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [], [])
|
||||
};
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<CommandException>(async () =>
|
||||
await Make(svc, mode: "delete-raw", start: TwoTimes[0], end: TwoTimes[1]).ExecuteAsync(console));
|
||||
}
|
||||
|
||||
/// <summary>An Uncertain item result is not Good and fails the command.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_UncertainItem_Throws()
|
||||
{
|
||||
var svc = new FakeOpcUaClientService
|
||||
{
|
||||
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
||||
[new List<StatusCode> { StatusCodes.UncertainDataSubNormal }])
|
||||
};
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<CommandException>(async () =>
|
||||
await Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]).ExecuteAsync(console));
|
||||
TestConsoleHelper.GetOutput(console).ShouldContain("UncertainDataSubNormal");
|
||||
}
|
||||
|
||||
/// <summary>A status the SDK has no name for prints as hex, never blank.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_UnnamedStatus_PrintsHex()
|
||||
{
|
||||
const uint unnamed = 0x8FFF0000u;
|
||||
StatusCodes.GetBrowseName(unnamed).ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
|
||||
var svc = new FakeOpcUaClientService
|
||||
{
|
||||
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
||||
[new List<StatusCode> { new(unnamed) }])
|
||||
};
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await Should.ThrowAsync<CommandException>(async () =>
|
||||
await Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]).ExecuteAsync(console));
|
||||
TestConsoleHelper.GetOutput(console).ShouldContain("0x8FFF0000");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user