chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// PR B.3 — pins the routing decision DriverNodeManager makes when registering
|
||||
/// an AlarmConditionState: drivers that implement <see cref="IAlarmSource"/>
|
||||
/// get an acknowledger that calls AcknowledgeAsync (driver-native path); drivers
|
||||
/// that don't fall back to the IWritable sub-attribute write.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverAlarmSourceAcknowledgerRoutingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Driver_with_IAlarmSource_is_recognized()
|
||||
{
|
||||
IDriver driver = new FakeDriverWithAlarmSource("drv-1");
|
||||
(driver is IAlarmSource).ShouldBeTrue(
|
||||
"fakes that participate in the routing-test fixture must report IAlarmSource");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_without_IAlarmSource_falls_to_writable_path()
|
||||
{
|
||||
IDriver driver = new FakeDriverNoAlarmSource("drv-2");
|
||||
(driver is IAlarmSource).ShouldBeFalse(
|
||||
"drivers without IAlarmSource take the legacy DriverWritableAcknowledger path");
|
||||
}
|
||||
|
||||
private sealed class FakeDriverWithAlarmSource(string id) : IDriver, IAlarmSource
|
||||
{
|
||||
public string DriverInstanceId { get; } = id;
|
||||
public string DriverType => "FakeAlarmSource";
|
||||
public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("h"));
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
private void NoUnusedWarning() => OnAlarmEvent?.Invoke(this, null!);
|
||||
}
|
||||
|
||||
private sealed class FakeDriverNoAlarmSource(string id) : IDriver
|
||||
{
|
||||
public string DriverInstanceId { get; } = id;
|
||||
public string DriverType => "FakeNoAlarmSource";
|
||||
public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeHandle(string id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId { get; } = id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user