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:
Joseph Doherty
2026-04-29 14:03:36 -04:00
parent 012c42a846
commit ef22a61c39
21 changed files with 3553 additions and 70 deletions

View File

@@ -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");
}
}

View File

@@ -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);
}
}