diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index 3ca40537..f5b659c1 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -221,6 +221,38 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ | `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation | | `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) | +### historyupdate + +Inserts, replaces, updates or deletes stored history on a historized node via the OPC UA `HistoryUpdate` +service (requires a server whose historian backend supports updates and a session holding the history-write +permission). Prints one line per item (`index time StatusCodeName`) followed by the overall service +result; any Bad item or service result exits non-zero. + +```bash +# Insert two values +otopcua-cli historyupdate -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=Plant/Modbus/dev1/Speed" \ + --mode insert --value 1.5 --time 2026-01-15T08:00:00Z --value 2.5 --time 2026-01-15T09:00:00Z + +# Replace an int64 value +otopcua-cli historyupdate -u ... -n "..." --mode replace --type int64 --value 42 --time 2026-01-15T08:00:00Z + +# Delete a raw range / specific timestamps +otopcua-cli historyupdate -u ... -n "..." --mode delete-raw --start 2026-01-15T08:00:00Z --end 2026-01-15T10:00:00Z +otopcua-cli historyupdate -u ... -n "..." --mode delete-at-time --time 2026-01-15T08:00:00Z +``` + +| Flag | Description | +|------|-------------| +| `-n` / `--node` | Node ID to modify (required) | +| `--mode` | `insert`, `replace`, `update`, `delete-raw` or `delete-at-time` (required) | +| `--value` | Value to write; repeatable, paired positionally with `--time` (insert/replace/update) | +| `--time` | ISO 8601 UTC timestamp; repeatable (insert/replace/update/delete-at-time) | +| `--type` | `double` (default), `int64`, `bool`, `string`, `datetime` - how `--value` is parsed | +| `--start` / `--end` | Range for `delete-raw` (both required; `--end` later than `--start`) | + +`delete-raw` is sent with `IsDeleteModified=false`. Invalid or inconsistent options (unknown mode, unpaired +value/time, unparsable value, options that do not apply to the mode) fail before any connection is made. + #### Aggregate mapping | Name | Aliases | OPC UA Node ID | diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs new file mode 100644 index 00000000..12669527 --- /dev/null +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryUpdateCommand.cs @@ -0,0 +1,246 @@ +using System.Globalization; +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Infrastructure; +using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers; +using ZB.MOM.WW.OtOpcUa.Client.Shared; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; + +[Command("historyupdate", Description = "Insert, replace, update or delete historical data on a node")] +public class HistoryUpdateCommand : CommandBase +{ + private static readonly string[] Modes = ["insert", "replace", "update", "delete-raw", "delete-at-time"]; + + /// + /// Creates the history-update command used to modify stored history for a node. + /// + /// The factory that creates the shared client service for the command run. + public HistoryUpdateCommand(IOpcUaClientServiceFactory factory) : base(factory) + { + } + + /// Gets the historized node to modify. + [CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)] + public string NodeId { get; init; } = default!; + + /// Gets the update mode. + [CommandOption("mode", Description = "insert | replace | update | delete-raw | delete-at-time", IsRequired = true)] + public string Mode { get; init; } = default!; + + /// Gets the values to write, paired positionally with . + [CommandOption("value", Description = "Value to write (repeatable; pairs positionally with --time) for insert/replace/update")] + public IReadOnlyList? Values { get; init; } + + /// Gets the source timestamps (ISO 8601 UTC), repeatable. + [CommandOption("time", Description = "ISO 8601 UTC timestamp (repeatable) for insert/replace/update/delete-at-time")] + public IReadOnlyList? Times { get; init; } + + /// Gets the value type used to parse . + [CommandOption("type", Description = "Value type: double (default), int64, bool, string, datetime")] + public string Type { get; init; } = "double"; + + /// Gets the start of the range for delete-raw. + [CommandOption("start", Description = "Range start (ISO 8601 UTC) for delete-raw")] + public string? StartTime { get; init; } + + /// Gets the end of the range for delete-raw. + [CommandOption("end", Description = "Range end (ISO 8601 UTC) for delete-raw")] + public string? EndTime { get; init; } + + /// + /// Validates the options and builds the HistoryUpdate details for the selected mode. + /// + /// The details object to send. + /// Thrown when options are missing, malformed or inconsistent. + public HistoryUpdateDetails BuildDetails() + { + NodeId nodeId; + try + { + nodeId = NodeIdParser.ParseRequired(NodeId); + } + catch (Exception ex) when (ex is FormatException or ArgumentException) + { + throw new CommandException($"Invalid --node value: {ex.Message}"); + } + + var mode = (Mode ?? string.Empty).Trim().ToLowerInvariant(); + if (Array.IndexOf(Modes, mode) < 0) + throw new CommandException( + $"Invalid --mode value '{Mode}'. Supported: {string.Join(", ", Modes)}."); + + var values = Values ?? []; + var times = Times ?? []; + var hasRange = !string.IsNullOrEmpty(StartTime) || !string.IsNullOrEmpty(EndTime); + + switch (mode) + { + case "insert": + case "replace": + case "update": + { + if (hasRange) + throw new CommandException($"--start/--end apply only to --mode delete-raw (mode was {mode})."); + if (values.Count == 0) + throw new CommandException($"--mode {mode} requires at least one --value/--time pair."); + if (values.Count != times.Count) + throw new CommandException( + $"--value and --time must be supplied in pairs ({values.Count} value(s), {times.Count} time(s))."); + + var updates = new DataValueCollection(); + for (var i = 0; i < values.Count; i++) + { + var ts = ParseTime(times[i], "--time"); + updates.Add(new DataValue(new Variant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts)); + } + + return new UpdateDataDetails + { + NodeId = nodeId, + PerformInsertReplace = mode switch + { + "insert" => PerformUpdateType.Insert, + "replace" => PerformUpdateType.Replace, + _ => PerformUpdateType.Update + }, + UpdateValues = updates + }; + } + case "delete-raw": + { + if (values.Count > 0 || times.Count > 0) + throw new CommandException("--mode delete-raw takes --start/--end, not --value/--time."); + if (string.IsNullOrEmpty(StartTime) || string.IsNullOrEmpty(EndTime)) + throw new CommandException("--mode delete-raw requires both --start and --end."); + var start = ParseTime(StartTime, "--start"); + var end = ParseTime(EndTime, "--end"); + if (end <= start) + throw new CommandException("--end must be later than --start."); + return new DeleteRawModifiedDetails + { + NodeId = nodeId, + IsDeleteModified = false, + StartTime = start, + EndTime = end + }; + } + default: // delete-at-time + { + if (hasRange) + throw new CommandException("--start/--end apply only to --mode delete-raw."); + if (values.Count > 0) + throw new CommandException("--mode delete-at-time takes --time only; --value is not allowed."); + if (times.Count == 0) + throw new CommandException("--mode delete-at-time requires at least one --time."); + var req = new DateTimeCollection(); + foreach (var t in times) req.Add(ParseTime(t, "--time")); + return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req }; + } + } + } + + /// + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var details = BuildDetails(); + var times = DescribeTimes(details); + + IOpcUaClientService? service = null; + try + { + var ct = console.RegisterCancellationHandler(); + (service, _) = await CreateServiceAndConnectAsync(ct); + + var outcome = await service.HistoryUpdateAsync(details, ct); + + await console.Output.WriteLineAsync($"HistoryUpdate {Mode.Trim().ToLowerInvariant()} on {NodeId}"); + await console.Output.WriteLineAsync($"{"Index",-6} {"Time",-35} {"Status"}"); + + var anyBad = false; + var ops = outcome.OperationResults.Count > 0 ? outcome.OperationResults[0] : []; + if (ops.Count > 0) + { + for (var i = 0; i < 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)}"); + } + } + 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)}"); + } + + 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 (anyBad) + throw new CommandException("HistoryUpdate reported one or more Bad results."); + } + finally + { + if (service != null) + { + await service.DisconnectAsync(); + service.Dispose(); + } + } + } + + private static List DescribeTimes(HistoryUpdateDetails details) + { + return details switch + { + UpdateDataDetails u => u.UpdateValues.Select(v => v.SourceTimestamp.ToString("O")).ToList(), + DeleteAtTimeDetails d => d.ReqTimes.Select(t => t.ToString("O")).ToList(), + DeleteRawModifiedDetails r => [$"{r.StartTime:O}..{r.EndTime:O}"], + _ => [] + }; + } + + private static DateTime ParseTime(string text, string option) + { + if (!DateTime.TryParse(text, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) + throw new CommandException( + $"Invalid {option} value '{text}'. Expected ISO 8601 UTC format, e.g. 2026-01-15T08:00:00Z."); + return parsed; + } + + private static object ParseValue(string text, string type) + { + var inv = CultureInfo.InvariantCulture; + switch ((type ?? "double").Trim().ToLowerInvariant()) + { + case "double": + if (double.TryParse(text, NumberStyles.Float, inv, out var d)) return d; + break; + case "int64": + if (long.TryParse(text, NumberStyles.Integer, inv, out var l)) return l; + break; + case "bool": + if (bool.TryParse(text, out var b)) return b; + if (text == "1") return true; + if (text == "0") return false; + break; + case "string": + return text; + case "datetime": + return ParseTime(text, "--value"); + default: + throw new CommandException( + $"Invalid --type value '{type}'. Supported: double, int64, bool, string, datetime."); + } + + throw new CommandException($"Invalid --value '{text}' for --type {type?.Trim().ToLowerInvariant()}."); + } +} diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs index 1d25779e..40a2205e 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs @@ -131,6 +131,26 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter return references != null && references.Count > 0; } + /// + public async Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct) + { + var response = await _session.HistoryUpdateAsync( + null, + new ExtensionObjectCollection { new ExtensionObject(details) }, + ct).ConfigureAwait(false); + + var statuses = new List(); + var ops = new List>(); + if (response.Results != null) + foreach (var r in response.Results) + { + statuses.Add(r.StatusCode); + ops.Add(r.OperationResults?.ToArray() ?? Array.Empty()); + } + + return new Models.HistoryUpdateOutcome(response.ResponseHeader.ServiceResult, statuses, ops); + } + /// public async Task> HistoryReadRawAsync( NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct) diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs index 8af4e721..7fc340fe 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/ISessionAdapter.cs @@ -110,6 +110,15 @@ internal interface ISessionAdapter : IDisposable Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default); + /// + /// Sends a HistoryUpdate request for the supplied details. + /// + /// The history update details. + /// The cancellation token that aborts the call. + /// The overall service result and per-item status codes. + Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default) + => throw new NotSupportedException("HistoryUpdate is not supported by this session adapter."); + /// /// Reads processed/aggregate historical data. /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs index 6874816f..c29ea0a3 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/IOpcUaClientService.cs @@ -193,6 +193,15 @@ public interface IOpcUaClientService : IDisposable Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default); + /// + /// Sends a HistoryUpdate request (insert/replace/update or delete) for the supplied details. + /// + /// The history update details (UpdateDataDetails, DeleteRawModifiedDetails, DeleteAtTimeDetails...). + /// The cancellation token that aborts the call. + /// The overall service result and per-item status codes. + Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default) + => throw new NotSupportedException("HistoryUpdate is not supported by this client service."); + /// /// Reads aggregate historical values for a historized node using an OPC UA aggregate function. /// diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/HistoryUpdateOutcome.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/HistoryUpdateOutcome.cs new file mode 100644 index 00000000..318f72a9 --- /dev/null +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/HistoryUpdateOutcome.cs @@ -0,0 +1,15 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models; + +/// +/// Result of a HistoryUpdate service call: the overall service result plus, per history-update result, +/// the operation-level status codes (one per value / requested time) and the result's own status code. +/// +/// The service-level result from the response header. +/// The per-details-entry status codes. +/// Per-details-entry operation status codes (one per value or requested time; may be empty). +public sealed record HistoryUpdateOutcome( + StatusCode ServiceResult, + IReadOnlyList ResultStatus, + IReadOnlyList> OperationResults); diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index 88cb407c..0c8b4e49 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -559,6 +559,14 @@ public sealed class OpcUaClientService : IOpcUaClientService return await _session!.HistoryReadRawAsync(nodeId, startTime, endTime, maxValues, ct); } + /// + public async Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default) + { + ThrowIfDisposed(); + ThrowIfNotConnected(); + return await _session!.HistoryUpdateAsync(details, ct); + } + /// public async Task> HistoryReadAggregateAsync( NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate, diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs index aeb22446..5c950179 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/Fakes/FakeOpcUaClientService.cs @@ -326,6 +326,20 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService return Task.FromResult(HistoryReadResult); } + /// Gets the details passed to HistoryUpdateAsync. + public List HistoryUpdateCalls { get; } = []; + + /// Gets or sets the outcome returned by HistoryUpdateAsync. + public HistoryUpdateOutcome HistoryUpdateResult { get; set; } = new( + StatusCodes.Good, [StatusCodes.Good], [new List { StatusCodes.Good }]); + + /// + public Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default) + { + HistoryUpdateCalls.Add(details); + return Task.FromResult(HistoryUpdateResult); + } + /// public Task> HistoryReadAggregateAsync( NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate, diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs new file mode 100644 index 00000000..9f299457 --- /dev/null +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryUpdateCommandTests.cs @@ -0,0 +1,133 @@ +using CliFx.Exceptions; +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands; +using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes; +using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; + +namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests; + +public class HistoryUpdateCommandTests +{ + private static HistoryUpdateCommand Make(FakeOpcUaClientService? svc = null, string mode = "insert", + string[]? values = null, string[]? times = null, string type = "double", string? start = null, + string? end = null, string node = "ns=2;s=HistNode") + => new(new FakeOpcUaClientServiceFactory(svc ?? new FakeOpcUaClientService())) + { + Url = "opc.tcp://localhost:4840", + NodeId = node, + Mode = mode, + Values = values, + Times = times, + Type = type, + StartTime = start, + EndTime = end + }; + + /// Insert builds UpdateDataDetails with Insert. + [Fact] + public void Insert_BuildsUpdateDataDetails() + { + var d = Make(values: ["1.5", "2.5"], times: ["2026-01-15T08:00:00Z", "2026-01-15T09:00:00Z"]) + .BuildDetails().ShouldBeOfType(); + d.PerformInsertReplace.ShouldBe(PerformUpdateType.Insert); + d.UpdateValues.Count.ShouldBe(2); + d.UpdateValues[1].Value.ShouldBe(2.5); + d.UpdateValues[0].SourceTimestamp.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc)); + d.NodeId.Identifier.ShouldBe("HistNode"); + } + + /// Replace and update map to their update types. + [Theory] + [InlineData("replace", PerformUpdateType.Replace)] + [InlineData("update", PerformUpdateType.Update)] + public void ReplaceAndUpdate_MapPerformType(string mode, PerformUpdateType expected) + { + var d = Make(mode: mode, values: ["1"], times: ["2026-01-15T08:00:00Z"]) + .BuildDetails().ShouldBeOfType(); + d.PerformInsertReplace.ShouldBe(expected); + } + + /// Each --type parses to the right CLR type. + [Theory] + [InlineData("double", "1.5", typeof(double))] + [InlineData("int64", "42", typeof(long))] + [InlineData("bool", "true", typeof(bool))] + [InlineData("string", "hello", typeof(string))] + [InlineData("datetime", "2026-01-15T08:00:00Z", typeof(DateTime))] + public void Type_ParsesValue(string type, string value, Type expected) + { + var d = Make(values: [value], times: ["2026-01-15T08:00:00Z"], type: type) + .BuildDetails().ShouldBeOfType(); + d.UpdateValues[0].Value.ShouldBeOfType(expected); + } + + /// delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false. + [Fact] + public void DeleteRaw_BuildsDetails() + { + var d = Make(mode: "delete-raw", start: "2026-01-15T08:00:00Z", end: "2026-01-15T09:00:00Z") + .BuildDetails().ShouldBeOfType(); + d.IsDeleteModified.ShouldBeFalse(); + d.StartTime.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc)); + d.EndTime.ShouldBe(new DateTime(2026, 1, 15, 9, 0, 0, DateTimeKind.Utc)); + } + + /// delete-at-time builds DeleteAtTimeDetails. + [Fact] + public void DeleteAtTime_BuildsDetails() + { + var d = Make(mode: "delete-at-time", times: ["2026-01-15T08:00:00Z", "2026-01-15T09:00:00Z"]) + .BuildDetails().ShouldBeOfType(); + d.ReqTimes.Count.ShouldBe(2); + } + + /// Invalid combinations are rejected with a CommandException. + [Fact] + public void InvalidArguments_Throw() + { + var t = new[] { "2026-01-15T08:00:00Z" }; + Should.Throw(() => Make(mode: "bogus").BuildDetails()); + Should.Throw(() => Make(values: ["1", "2"], times: t).BuildDetails()); + Should.Throw(() => Make(values: null, times: t).BuildDetails()); + Should.Throw(() => Make(values: ["abc"], times: t).BuildDetails()); + Should.Throw(() => Make(values: ["1"], times: ["notatime"]).BuildDetails()); + Should.Throw(() => Make(values: ["1"], times: t, type: "float").BuildDetails()); + Should.Throw(() => Make(mode: "delete-raw", start: "2026-01-15T08:00:00Z").BuildDetails()); + Should.Throw(() => Make(mode: "delete-raw", start: "2026-01-15T09:00:00Z", + end: "2026-01-15T08:00:00Z").BuildDetails()); + Should.Throw(() => Make(mode: "delete-at-time").BuildDetails()); + Should.Throw(() => Make(mode: "delete-at-time", values: ["1"], times: t).BuildDetails()); + Should.Throw(() => Make(node: "").BuildDetails()); + } + + /// Execute sends the details and prints one line per item with the status name. + [Fact] + public async Task Execute_PrintsPerItemStatus() + { + var svc = new FakeOpcUaClientService(); + var cmd = Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]); + using var console = TestConsoleHelper.CreateConsole(); + await cmd.ExecuteAsync(console); + svc.HistoryUpdateCalls.Count.ShouldBe(1); + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("Good"); + output.ShouldContain("Service result: Good"); + } + + /// A Bad item fails the command (non-zero exit) after printing. + [Fact] + public async Task Execute_BadItem_Throws() + { + var svc = new FakeOpcUaClientService + { + HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good], + [new List { StatusCodes.BadEntryExists }]) + }; + var cmd = Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]); + using var console = TestConsoleHelper.CreateConsole(); + await Should.ThrowAsync(async () => await cmd.ExecuteAsync(console)); + TestConsoleHelper.GetOutput(console).ShouldContain("BadEntryExists"); + } +}