feat(cli): historyupdate command (insert/replace/update/delete-raw/delete-at-time)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 12:47:53 -04:00
parent ff7c6c531a
commit 70dde115f0
9 changed files with 486 additions and 0 deletions
@@ -326,6 +326,20 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryReadResult);
}
/// <summary>Gets the details passed to HistoryUpdateAsync.</summary>
public List<HistoryUpdateDetails> HistoryUpdateCalls { get; } = [];
/// <summary>Gets or sets the outcome returned by HistoryUpdateAsync.</summary>
public HistoryUpdateOutcome HistoryUpdateResult { get; set; } = new(
StatusCodes.Good, [StatusCodes.Good], [new List<StatusCode> { StatusCodes.Good }]);
/// <inheritdoc />
public Task<HistoryUpdateOutcome> HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default)
{
HistoryUpdateCalls.Add(details);
return Task.FromResult(HistoryUpdateResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate,
@@ -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
};
/// <summary>Insert builds UpdateDataDetails with Insert.</summary>
[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<UpdateDataDetails>();
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");
}
/// <summary>Replace and update map to their update types.</summary>
[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<UpdateDataDetails>();
d.PerformInsertReplace.ShouldBe(expected);
}
/// <summary>Each --type parses to the right CLR type.</summary>
[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<UpdateDataDetails>();
d.UpdateValues[0].Value.ShouldBeOfType(expected);
}
/// <summary>delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false.</summary>
[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<DeleteRawModifiedDetails>();
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));
}
/// <summary>delete-at-time builds DeleteAtTimeDetails.</summary>
[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<DeleteAtTimeDetails>();
d.ReqTimes.Count.ShouldBe(2);
}
/// <summary>Invalid combinations are rejected with a CommandException.</summary>
[Fact]
public void InvalidArguments_Throw()
{
var t = new[] { "2026-01-15T08:00:00Z" };
Should.Throw<CommandException>(() => Make(mode: "bogus").BuildDetails());
Should.Throw<CommandException>(() => Make(values: ["1", "2"], times: t).BuildDetails());
Should.Throw<CommandException>(() => Make(values: null, times: t).BuildDetails());
Should.Throw<CommandException>(() => Make(values: ["abc"], times: t).BuildDetails());
Should.Throw<CommandException>(() => Make(values: ["1"], times: ["notatime"]).BuildDetails());
Should.Throw<CommandException>(() => Make(values: ["1"], times: t, type: "float").BuildDetails());
Should.Throw<CommandException>(() => Make(mode: "delete-raw", start: "2026-01-15T08:00:00Z").BuildDetails());
Should.Throw<CommandException>(() => Make(mode: "delete-raw", start: "2026-01-15T09:00:00Z",
end: "2026-01-15T08:00:00Z").BuildDetails());
Should.Throw<CommandException>(() => Make(mode: "delete-at-time").BuildDetails());
Should.Throw<CommandException>(() => Make(mode: "delete-at-time", values: ["1"], times: t).BuildDetails());
Should.Throw<CommandException>(() => Make(node: "").BuildDetails());
}
/// <summary>Execute sends the details and prints one line per item with the status name.</summary>
[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");
}
/// <summary>A Bad item fails the command (non-zero exit) after printing.</summary>
[Fact]
public async Task Execute_BadItem_Throws()
{
var svc = new FakeOpcUaClientService
{
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
[new List<StatusCode> { StatusCodes.BadEntryExists }])
};
var cmd = Make(svc, values: ["1"], times: ["2026-01-15T08:00:00Z"]);
using var console = TestConsoleHelper.CreateConsole();
await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
TestConsoleHelper.GetOutput(console).ShouldContain("BadEntryExists");
}
}