v2 mxgw migration — Phase 1+2+3.1 wiring (7 PRs)
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>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the <see cref="AlarmConditionInfo"/> record extension added in PR 2.1.
|
||||
/// Five sub-attribute references (InAlarmRef, PriorityRef, DescAttrNameRef, AckedRef,
|
||||
/// AckMsgWriteRef) carry the driver-side tag references the server-level alarm-condition
|
||||
/// service uses to subscribe to live alarm-state attributes and route ack writes.
|
||||
/// </summary>
|
||||
public sealed class AlarmConditionInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void LegacyThreeArgConstructor_StillCompiles_AndDefaultsRefsToNull()
|
||||
{
|
||||
var info = new AlarmConditionInfo(
|
||||
SourceName: "Tank.HiHi",
|
||||
InitialSeverity: AlarmSeverity.High,
|
||||
InitialDescription: "High-high alarm");
|
||||
|
||||
info.SourceName.ShouldBe("Tank.HiHi");
|
||||
info.InitialSeverity.ShouldBe(AlarmSeverity.High);
|
||||
info.InitialDescription.ShouldBe("High-high alarm");
|
||||
info.InAlarmRef.ShouldBeNull();
|
||||
info.PriorityRef.ShouldBeNull();
|
||||
info.DescAttrNameRef.ShouldBeNull();
|
||||
info.AckedRef.ShouldBeNull();
|
||||
info.AckMsgWriteRef.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullConstructor_PopulatesAllFiveSubAttributeRefs()
|
||||
{
|
||||
var info = new AlarmConditionInfo(
|
||||
SourceName: "Tank1.HiAlarm",
|
||||
InitialSeverity: AlarmSeverity.Medium,
|
||||
InitialDescription: "Tank level high",
|
||||
InAlarmRef: "Tank1.HiAlarm.InAlarm",
|
||||
PriorityRef: "Tank1.HiAlarm.Priority",
|
||||
DescAttrNameRef: "Tank1.HiAlarm.DescAttrName",
|
||||
AckedRef: "Tank1.HiAlarm.Acked",
|
||||
AckMsgWriteRef: "Tank1.HiAlarm.AckMsg");
|
||||
|
||||
info.InAlarmRef.ShouldBe("Tank1.HiAlarm.InAlarm");
|
||||
info.PriorityRef.ShouldBe("Tank1.HiAlarm.Priority");
|
||||
info.DescAttrNameRef.ShouldBe("Tank1.HiAlarm.DescAttrName");
|
||||
info.AckedRef.ShouldBe("Tank1.HiAlarm.Acked");
|
||||
info.AckMsgWriteRef.ShouldBe("Tank1.HiAlarm.AckMsg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordEquality_ComparesAllEightFields()
|
||||
{
|
||||
var a = new AlarmConditionInfo(
|
||||
"T.Alarm", AlarmSeverity.Low, "desc",
|
||||
"T.Alarm.InAlarm", "T.Alarm.Priority", "T.Alarm.DescAttrName",
|
||||
"T.Alarm.Acked", "T.Alarm.AckMsg");
|
||||
|
||||
var b = new AlarmConditionInfo(
|
||||
"T.Alarm", AlarmSeverity.Low, "desc",
|
||||
"T.Alarm.InAlarm", "T.Alarm.Priority", "T.Alarm.DescAttrName",
|
||||
"T.Alarm.Acked", "T.Alarm.AckMsg");
|
||||
|
||||
a.ShouldBe(b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordEquality_DistinctWhenAnyRefDiffers()
|
||||
{
|
||||
var baseInfo = new AlarmConditionInfo(
|
||||
"T.Alarm", AlarmSeverity.Low, "desc",
|
||||
InAlarmRef: "T.Alarm.InAlarm");
|
||||
|
||||
var differingAckRef = baseInfo with { AckedRef = "T.Alarm.Acked" };
|
||||
|
||||
baseInfo.ShouldNotBe(differingAckRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithExpression_AllowsPartialUpdates()
|
||||
{
|
||||
var legacy = new AlarmConditionInfo("S", AlarmSeverity.Medium, null);
|
||||
|
||||
var enriched = legacy with
|
||||
{
|
||||
InAlarmRef = "S.InAlarm",
|
||||
AckedRef = "S.Acked",
|
||||
AckMsgWriteRef = "S.AckMsg",
|
||||
};
|
||||
|
||||
enriched.SourceName.ShouldBe("S");
|
||||
enriched.InAlarmRef.ShouldBe("S.InAlarm");
|
||||
enriched.PriorityRef.ShouldBeNull();
|
||||
enriched.DescAttrNameRef.ShouldBeNull();
|
||||
enriched.AckedRef.ShouldBe("S.Acked");
|
||||
enriched.AckMsgWriteRef.ShouldBe("S.AckMsg");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke test confirming the sidecar project links and the test project resolves a
|
||||
/// ProjectReference to it. Real behavioural tests arrive in PR 3.2 (backend lift) and
|
||||
/// PR 3.3 (pipe server). For PR 3.1 we just verify the assembly identity is what the
|
||||
/// csproj declares.
|
||||
/// </summary>
|
||||
public class ProgramSmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Program_Assembly_HasExpectedName()
|
||||
{
|
||||
typeof(Program).Assembly.GetName().Name
|
||||
.ShouldBe("OtOpcUa.Driver.Historian.Wonderware");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,331 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Server-level alarm-condition state-machine tests added in PR 2.2. Ports the live
|
||||
/// transition cases from <c>GalaxyAlarmTrackerTests</c> against the new
|
||||
/// driver-agnostic <see cref="AlarmConditionService"/>: sub-attribute references come
|
||||
/// from <see cref="AlarmConditionInfo"/>, value changes flow as
|
||||
/// <see cref="DataValueSnapshot"/> instead of MX-specific <c>Vtq</c>, and the ack
|
||||
/// write path is decoupled into <see cref="IAlarmAcknowledger"/>.
|
||||
/// </summary>
|
||||
public sealed class AlarmConditionServiceTests
|
||||
{
|
||||
private const string ConditionId = "TankFarm.Tank1.Level.HiHi";
|
||||
private const string InAlarmRef = "TankFarm.Tank1.Level.HiHi.InAlarm";
|
||||
private const string PriorityRef = "TankFarm.Tank1.Level.HiHi.Priority";
|
||||
private const string DescRef = "TankFarm.Tank1.Level.HiHi.DescAttrName";
|
||||
private const string AckedRef = "TankFarm.Tank1.Level.HiHi.Acked";
|
||||
private const string AckMsgWriteRef = "TankFarm.Tank1.Level.HiHi.AckMsg";
|
||||
|
||||
private static AlarmConditionInfo Info(
|
||||
string? inAlarm = InAlarmRef, string? priority = PriorityRef,
|
||||
string? desc = DescRef, string? acked = AckedRef, string? ackMsg = AckMsgWriteRef)
|
||||
=> new(
|
||||
SourceName: ConditionId,
|
||||
InitialSeverity: AlarmSeverity.Medium,
|
||||
InitialDescription: null,
|
||||
InAlarmRef: inAlarm,
|
||||
PriorityRef: priority,
|
||||
DescAttrNameRef: desc,
|
||||
AckedRef: acked,
|
||||
AckMsgWriteRef: ackMsg);
|
||||
|
||||
private static DataValueSnapshot Bool(bool v) =>
|
||||
new(v, StatusCode: 0, SourceTimestampUtc: DateTime.UtcNow, ServerTimestampUtc: DateTime.UtcNow);
|
||||
private static DataValueSnapshot Int(int v) =>
|
||||
new(v, 0, DateTime.UtcNow, DateTime.UtcNow);
|
||||
private static DataValueSnapshot Str(string v) =>
|
||||
new(v, 0, DateTime.UtcNow, DateTime.UtcNow);
|
||||
|
||||
private sealed class FakeAcker : IAlarmAcknowledger
|
||||
{
|
||||
public readonly ConcurrentQueue<(string Ref, string Comment)> Writes = new();
|
||||
public bool ReturnValue { get; set; } = true;
|
||||
|
||||
public Task<bool> WriteAckMessageAsync(string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
|
||||
{
|
||||
Writes.Enqueue((ackMsgWriteRef, comment));
|
||||
return Task.FromResult(ReturnValue);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_AddsCondition_AndExposesSubscribedReferences()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.TrackedCount.ShouldBe(1);
|
||||
var refs = svc.GetSubscribedReferences();
|
||||
refs.ShouldContain(InAlarmRef);
|
||||
refs.ShouldContain(PriorityRef);
|
||||
refs.ShouldContain(DescRef);
|
||||
refs.ShouldContain(AckedRef);
|
||||
refs.Count.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_IsIdempotentOnRepeatCall()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
|
||||
svc.Track(ConditionId, Info());
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.TrackedCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_OmitsNullSubAttributeRefs()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
|
||||
// Driver may not expose every sub-attribute (e.g. no .Acked observable).
|
||||
svc.Track(ConditionId, Info(priority: null, desc: null, acked: null));
|
||||
|
||||
svc.GetSubscribedReferences().ShouldBe(new[] { InAlarmRef });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InAlarmFalseToTrue_FiresActiveTransition()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.OnValueChanged(PriorityRef, Int(500));
|
||||
svc.OnValueChanged(DescRef, Str("Tank level high-high"));
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true));
|
||||
|
||||
transitions.Count.ShouldBe(1);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.Transition.ShouldBe(AlarmStateTransition.Active);
|
||||
t.Priority.ShouldBe(500);
|
||||
t.Description.ShouldBe("Tank level high-high");
|
||||
t.ConditionId.ShouldBe(ConditionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InAlarmTrueToFalse_FiresInactiveTransition()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true));
|
||||
svc.OnValueChanged(InAlarmRef, Bool(false));
|
||||
|
||||
transitions.Count.ShouldBe(2);
|
||||
transitions.TryDequeue(out _);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.Transition.ShouldBe(AlarmStateTransition.Inactive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AckedFalseToTrue_FiresAcknowledged_WhileActive()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true)); // Active, resets Acked → false
|
||||
svc.OnValueChanged(AckedRef, Bool(true)); // Acknowledged
|
||||
|
||||
transitions.Count.ShouldBe(2);
|
||||
transitions.TryDequeue(out _);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.Transition.ShouldBe(AlarmStateTransition.Acknowledged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AckedTransitionWhileInactive_DoesNotFire()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
// Initial Acked=true on subscribe (alarm at rest, pre-ack'd) — must not fire.
|
||||
svc.OnValueChanged(AckedRef, Bool(true));
|
||||
|
||||
transitions.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedActiveTransitions_ResetAckedFlag()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
// Cycle 1: active → ack → inactive → active again
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true));
|
||||
svc.OnValueChanged(AckedRef, Bool(true));
|
||||
svc.OnValueChanged(InAlarmRef, Bool(false));
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true)); // re-arms — Acked must reset to false
|
||||
svc.OnValueChanged(AckedRef, Bool(true)); // produces a fresh Acknowledged
|
||||
|
||||
// Active, Acknowledged, Inactive, Active, Acknowledged
|
||||
transitions.Count.ShouldBe(5);
|
||||
var ordered = transitions.Select(t => t.Transition).ToArray();
|
||||
ordered.ShouldBe(new[]
|
||||
{
|
||||
AlarmStateTransition.Active,
|
||||
AlarmStateTransition.Acknowledged,
|
||||
AlarmStateTransition.Inactive,
|
||||
AlarmStateTransition.Active,
|
||||
AlarmStateTransition.Acknowledged,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_RoutesToAckerWithAckMsgRef()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var acker = new FakeAcker();
|
||||
svc.Track(ConditionId, Info(), acker);
|
||||
|
||||
var ok = await svc.AcknowledgeAsync(ConditionId, "operator-1: cleared", CancellationToken.None);
|
||||
|
||||
ok.ShouldBeTrue();
|
||||
acker.Writes.Count.ShouldBe(1);
|
||||
acker.Writes.TryDequeue(out var w).ShouldBeTrue();
|
||||
w.Ref.ShouldBe(AckMsgWriteRef);
|
||||
w.Comment.ShouldBe("operator-1: cleared");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_ReturnsFalse_WhenConditionUntracked()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var acker = new FakeAcker();
|
||||
svc.Track("OtherCondition", Info(), acker);
|
||||
|
||||
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
|
||||
|
||||
ok.ShouldBeFalse();
|
||||
acker.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_ReturnsFalse_WhenNoAckerRegistered()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info(), acker: null);
|
||||
|
||||
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
|
||||
|
||||
ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_ReturnsFalse_WhenAckMsgRefMissing()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var acker = new FakeAcker();
|
||||
svc.Track(ConditionId, Info(ackMsg: null), acker);
|
||||
|
||||
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
|
||||
|
||||
ok.ShouldBeFalse();
|
||||
acker.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ReportsLatestFields()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info());
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true));
|
||||
svc.OnValueChanged(PriorityRef, Int(900));
|
||||
svc.OnValueChanged(DescRef, Str("MyAlarm"));
|
||||
svc.OnValueChanged(AckedRef, Bool(true));
|
||||
|
||||
var snap = svc.Snapshot();
|
||||
|
||||
snap.Count.ShouldBe(1);
|
||||
snap[0].ConditionId.ShouldBe(ConditionId);
|
||||
snap[0].InAlarm.ShouldBeTrue();
|
||||
snap[0].Acked.ShouldBeTrue();
|
||||
snap[0].Priority.ShouldBe(900);
|
||||
snap[0].Description.ShouldBe("MyAlarm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnValueChanged_ForUnknownReference_IsSilentlyIgnored()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
|
||||
svc.OnValueChanged("Some.Random.Tag.InAlarm", Bool(true));
|
||||
|
||||
transitions.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untrack_RemovesConditionAndReleasesReferences()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.Untrack(ConditionId);
|
||||
|
||||
svc.TrackedCount.ShouldBe(0);
|
||||
svc.GetSubscribedReferences().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untrack_NonexistentConditionIsNoOp()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
Should.NotThrow(() => svc.Untrack("does-not-exist"));
|
||||
svc.TrackedCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_ThrowsAfterDisposal()
|
||||
{
|
||||
var svc = new AlarmConditionService();
|
||||
svc.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(() => svc.Track(ConditionId, Info()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnValueChanged_AfterDisposal_IsSilentlyDropped()
|
||||
{
|
||||
var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info());
|
||||
svc.Dispose();
|
||||
|
||||
// Stale callbacks during disposal must not throw.
|
||||
Should.NotThrow(() => svc.OnValueChanged(InAlarmRef, Bool(true)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityCoercion_AcceptsCommonNumericTypes()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.OnValueChanged(PriorityRef, new DataValueSnapshot((short)123, 0, null, DateTime.UtcNow));
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true));
|
||||
|
||||
var snap = svc.Snapshot()[0];
|
||||
snap.Priority.ShouldBe(123);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.History;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HistoryRouter"/> registration + resolution semantics added
|
||||
/// in PR 1.2. The router is the only seam between OPC UA HistoryRead service calls
|
||||
/// and registered <see cref="IHistorianDataSource"/> implementations, so the
|
||||
/// resolution rules (case-insensitive prefix, longest-match wins, no source =>
|
||||
/// null) need explicit coverage.
|
||||
/// </summary>
|
||||
public sealed class HistoryRouterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_ReturnsNull_WhenNoSourceRegistered()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
router.Resolve("anything").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ReturnsRegisteredSource_WhenPrefixMatches()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
var source = new FakeSource("galaxy");
|
||||
router.Register("galaxy", source);
|
||||
|
||||
router.Resolve("galaxy.TankFarm.Tank1.Level").ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ReturnsNull_WhenPrefixDoesNotMatch()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
router.Register("galaxy", new FakeSource("galaxy"));
|
||||
|
||||
router.Resolve("modbus.MyDevice.Tag1").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_LongestPrefixWins_WhenMultipleRegistered()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
var generic = new FakeSource("generic");
|
||||
var specific = new FakeSource("specific");
|
||||
|
||||
router.Register("galaxy", generic);
|
||||
router.Register("galaxy.HighRate", specific);
|
||||
|
||||
router.Resolve("galaxy.HighRate.Sensor1").ShouldBe(specific);
|
||||
router.Resolve("galaxy.LowRate.Sensor2").ShouldBe(generic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_IsCaseInsensitive_OnPrefixMatch()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
var source = new FakeSource("galaxy");
|
||||
router.Register("Galaxy", source);
|
||||
|
||||
router.Resolve("galaxy.foo").ShouldBe(source);
|
||||
router.Resolve("GALAXY.foo").ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_Throws_WhenPrefixAlreadyRegistered()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
router.Register("galaxy", new FakeSource("first"));
|
||||
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => router.Register("galaxy", new FakeSource("second")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DisposesAllRegisteredSources()
|
||||
{
|
||||
var router = new HistoryRouter();
|
||||
var a = new FakeSource("a");
|
||||
var b = new FakeSource("b");
|
||||
router.Register("ns_a", a);
|
||||
router.Register("ns_b", b);
|
||||
|
||||
router.Dispose();
|
||||
|
||||
a.IsDisposed.ShouldBeTrue();
|
||||
b.IsDisposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_SwallowsExceptionsFromMisbehavingSource()
|
||||
{
|
||||
var router = new HistoryRouter();
|
||||
var throwing = new ThrowingFakeSource();
|
||||
var clean = new FakeSource("clean");
|
||||
router.Register("bad", throwing);
|
||||
router.Register("good", clean);
|
||||
|
||||
// Even when one source's Dispose throws, the router must finish disposing the
|
||||
// remaining sources (server shutdown invariant).
|
||||
Should.NotThrow(() => router.Dispose());
|
||||
clean.IsDisposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Throws_AfterDisposal()
|
||||
{
|
||||
var router = new HistoryRouter();
|
||||
router.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(() => router.Resolve("anything"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_Throws_AfterDisposal()
|
||||
{
|
||||
var router = new HistoryRouter();
|
||||
router.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(
|
||||
() => router.Register("ns", new FakeSource("x")));
|
||||
}
|
||||
|
||||
private sealed class FakeSource(string name) : IHistorianDataSource
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
public void Dispose() => IsDisposed = true;
|
||||
|
||||
public Task<HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot()
|
||||
=> new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
|
||||
}
|
||||
|
||||
private sealed class ThrowingFakeSource : IHistorianDataSource
|
||||
{
|
||||
public void Dispose() => throw new InvalidOperationException("boom");
|
||||
|
||||
public Task<HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot()
|
||||
=> new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user