Foundational PRs from lmx_mxgw_impl.md, all green. Bodies only — DI/wiring deferred to PR 1+2.W (combined wire-up) and PR 3.W. PR 1.1 — IHistorianDataSource lifted to Core.Abstractions/Historian/ Reuses existing DataValueSnapshot + HistoricalEvent shapes; sidecar (PR 3.4) translates byte-quality → uint StatusCode internally. PR 1.2 — IHistoryRouter + HistoryRouter on the server Longest-prefix-match resolution, case-insensitive, ObjectDisposed-guarded, swallow-on-shutdown disposal of misbehaving sources. PR 1.3 — DriverNodeManager.HistoryRead* dispatch through IHistoryRouter Per-tag resolution with LegacyDriverHistoryAdapter wrapping `_driver as IHistoryProvider` so existing tests + drivers keep working until PR 7.2 retires the fallback. PR 2.1 — AlarmConditionInfo extended with five sub-attribute refs InAlarmRef / PriorityRef / DescAttrNameRef / AckedRef / AckMsgWriteRef. Optional defaulted parameters preserve all existing 3-arg call sites. PR 2.2 — AlarmConditionService state machine in Server/Alarms/ Driver-agnostic port of GalaxyAlarmTracker. Sub-attribute refs come from AlarmConditionInfo, values arrive as DataValueSnapshot, ack writes route through IAlarmAcknowledger. State machine preserves Active/Acknowledged/ Inactive transitions, Acked-on-active reset, post-disposal silence. PR 2.3 — DriverNodeManager wires AlarmConditionService MarkAsAlarmCondition registers each alarm-bearing variable with the service; DriverWritableAcknowledger routes ack-message writes through the driver's IWritable + CapabilityInvoker. Service-raised transitions route via OnAlarmServiceTransition → matching ConditionSink. Legacy IAlarmSource path unchanged for null service. PR 3.1 — Driver.Historian.Wonderware shell project (net48 x86) Console host shell + smoke test; SDK references + code lift come in PR 3.2. Tests: 9 (PR 1.1) + 5 (PR 2.1) + 10 (PR 1.2) + 19 (PR 2.2) + 1 (PR 3.1) all pass. Existing AlarmSubscribeIntegrationTests + HistoryReadIntegrationTests unchanged. Plan + audit docs (lmx_backend.md, lmx_mxgw.md, lmx_mxgw_impl.md) included so parallel subagent worktrees can read them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
3.9 KiB
C#
122 lines
3.9 KiB
C#
using System.Reflection;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
|
|
|
|
/// <summary>
|
|
/// Structural contract tests for the historian data-source surface added in PR 1.1.
|
|
/// Asserts the type shape — implementations are tested in their own projects.
|
|
/// </summary>
|
|
public sealed class IHistorianDataSourceContractTests
|
|
{
|
|
[Fact]
|
|
public void Interface_LivesInRootNamespace()
|
|
{
|
|
typeof(IHistorianDataSource).Namespace
|
|
.ShouldBe("ZB.MOM.WW.OtOpcUa.Core.Abstractions");
|
|
}
|
|
|
|
[Fact]
|
|
public void Interface_IsPublic()
|
|
{
|
|
typeof(IHistorianDataSource).IsPublic.ShouldBeTrue();
|
|
typeof(IHistorianDataSource).IsInterface.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Interface_ExtendsIDisposable()
|
|
{
|
|
typeof(IDisposable).IsAssignableFrom(typeof(IHistorianDataSource))
|
|
.ShouldBeTrue("data sources own backend connections; the server disposes them on shutdown");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ReadRawAsync", typeof(Task<HistoryReadResult>))]
|
|
[InlineData("ReadProcessedAsync", typeof(Task<HistoryReadResult>))]
|
|
[InlineData("ReadAtTimeAsync", typeof(Task<HistoryReadResult>))]
|
|
[InlineData("ReadEventsAsync", typeof(Task<HistoricalEventsResult>))]
|
|
public void ReadMethods_ReturnExpectedTaskShape(string methodName, Type expectedReturnType)
|
|
{
|
|
var method = typeof(IHistorianDataSource).GetMethod(methodName);
|
|
method.ShouldNotBeNull();
|
|
method!.ReturnType.ShouldBe(expectedReturnType);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetHealthSnapshot_IsSynchronous()
|
|
{
|
|
var method = typeof(IHistorianDataSource).GetMethod("GetHealthSnapshot");
|
|
method.ShouldNotBeNull();
|
|
method!.ReturnType.ShouldBe(typeof(HistorianHealthSnapshot));
|
|
}
|
|
|
|
[Fact]
|
|
public void HealthSnapshot_AcceptsEmptyClusterNodeList()
|
|
{
|
|
var snapshot = new HistorianHealthSnapshot(
|
|
TotalQueries: 0,
|
|
TotalSuccesses: 0,
|
|
TotalFailures: 0,
|
|
ConsecutiveFailures: 0,
|
|
LastSuccessTime: null,
|
|
LastFailureTime: null,
|
|
LastError: null,
|
|
ProcessConnectionOpen: false,
|
|
EventConnectionOpen: false,
|
|
ActiveProcessNode: null,
|
|
ActiveEventNode: null,
|
|
Nodes: Array.Empty<HistorianClusterNodeState>());
|
|
|
|
snapshot.Nodes.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void HealthSnapshot_PreservesClusterNodes()
|
|
{
|
|
var node = new HistorianClusterNodeState(
|
|
Name: "hist-01",
|
|
IsHealthy: true,
|
|
CooldownUntil: null,
|
|
FailureCount: 0,
|
|
LastError: null,
|
|
LastFailureTime: null);
|
|
|
|
var snapshot = new HistorianHealthSnapshot(
|
|
TotalQueries: 5,
|
|
TotalSuccesses: 5,
|
|
TotalFailures: 0,
|
|
ConsecutiveFailures: 0,
|
|
LastSuccessTime: new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc),
|
|
LastFailureTime: null,
|
|
LastError: null,
|
|
ProcessConnectionOpen: true,
|
|
EventConnectionOpen: true,
|
|
ActiveProcessNode: "hist-01",
|
|
ActiveEventNode: "hist-01",
|
|
Nodes: new[] { node });
|
|
|
|
snapshot.Nodes.Count.ShouldBe(1);
|
|
snapshot.Nodes[0].ShouldBe(node);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClusterNodeState_RecordEqualityByValue()
|
|
{
|
|
var a = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
|
|
var b = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
|
|
|
|
a.ShouldBe(b);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClusterNodeState_DistinctByAnyField()
|
|
{
|
|
var healthy = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
|
|
var unhealthy = new HistorianClusterNodeState("hist-01", false, null, 1, "boom", null);
|
|
|
|
healthy.ShouldNotBe(unhealthy);
|
|
}
|
|
}
|