worker(alarms): COM-backed LmxSubtagAlarmSource advising alarm subtags

This commit is contained in:
Joseph Doherty
2026-06-13 09:24:09 -04:00
parent c75920c620
commit 4c0e14fc5d
2 changed files with 543 additions and 0 deletions
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit-test coverage for <see cref="LmxSubtagAlarmSource"/>'s advise/write
/// sequencing and its <c>OnDataChange</c> normalization. The actual
/// <c>LMXProxyServerClass</c> COM event subscription cannot be exercised
/// without a live MXAccess install, so these tests drive the source through
/// its internal <see cref="IMxAccessServer"/> seam and call
/// <c>HandleDataChange</c> directly to simulate a COM callback — exactly the
/// boundary <c>MxAccessBaseEventSink.OnDataChange</c> uses for the
/// per-session pipeline. End-to-end COM delivery is covered by the
/// Skip-gated alarm live smoke tests.
/// </summary>
public sealed class LmxSubtagAlarmSourceTests
{
private const int FakeServerHandle = 7;
/// <summary>Verifies the production constructor rejects a null factory.</summary>
[Fact]
public void Constructor_NullFactory_Throws()
{
Assert.Throws<ArgumentNullException>(() => new LmxSubtagAlarmSource(factory: null!));
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Advise"/> calls AddItem then
/// Advise once per distinct address, and is idempotent on a repeated
/// address.
/// </summary>
[Fact]
public void Advise_AddsAndAdvisesEachAddressOnce()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" });
// Re-advising an already-advised address is a no-op.
source.Advise(new[] { "Tank1.Alarm.Subtag" });
Assert.Equal(
new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" },
server.AddedItems);
Assert.Equal(2, server.AdviseCount);
// Every advise targeted the supplied server handle.
Assert.All(server.AdvisedServerHandles, h => Assert.Equal(FakeServerHandle, h));
}
/// <summary>
/// Verifies a simulated <c>OnDataChange</c> for an advised item handle
/// raises <see cref="LmxSubtagAlarmSource.ValueChanged"/> with the
/// address that was advised and the delivered value.
/// </summary>
[Fact]
public void HandleDataChange_RaisesValueChangedWithAdvisedAddress()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag" });
int itemHandle = server.LastItemHandleFor("Tank1.Alarm.Subtag");
SubtagValueChange? received = null;
source.ValueChanged += (_, change) => received = change;
source.HandleDataChange(itemHandle, pvItemValue: 42, pftItemTimeStamp: null);
Assert.NotNull(received);
Assert.Equal("Tank1.Alarm.Subtag", received!.ItemAddress);
Assert.Equal(42, received.Value);
Assert.Equal(DateTimeKind.Utc, received.TimestampUtc.Kind);
}
/// <summary>
/// Verifies <c>OnDataChange</c> for an unknown item handle is ignored
/// (no <see cref="LmxSubtagAlarmSource.ValueChanged"/> raised).
/// </summary>
[Fact]
public void HandleDataChange_UnknownHandle_DoesNotRaise()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
bool raised = false;
source.ValueChanged += (_, _) => raised = true;
source.HandleDataChange(phItemHandle: 999, pvItemValue: 1, pftItemTimeStamp: null);
Assert.False(raised);
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> adds the item when
/// it was not previously advised and writes with user id 0.
/// </summary>
[Fact]
public void Write_AddsItemWhenUnknownAndWrites()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Write("Tank1.Alarm.AckComment", "acknowledged");
Assert.Contains("Tank1.Alarm.AckComment", server.AddedItems);
Assert.Single(server.Writes);
RecordingMxAccessServer.WriteRecord write = server.Writes[0];
Assert.Equal(FakeServerHandle, write.ServerHandle);
Assert.Equal("acknowledged", write.Value);
Assert.Equal(0, write.UserId);
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> reuses an existing
/// item handle (no duplicate AddItem) when the address was already
/// advised.
/// </summary>
[Fact]
public void Write_ReusesHandleForAdvisedAddress()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag" });
int adviseAddCount = server.AddedItems.Count;
source.Write("Tank1.Alarm.Subtag", true);
// No second AddItem for the already-advised address.
Assert.Equal(adviseAddCount, server.AddedItems.Count);
Assert.Single(server.Writes);
}
/// <summary>
/// Recording <see cref="IMxAccessServer"/> test double that captures the
/// AddItem/Advise/Write/UnAdvise/RemoveItem/Unregister calls
/// <see cref="LmxSubtagAlarmSource"/> makes and hands out monotonically
/// increasing item handles.
/// </summary>
private sealed class RecordingMxAccessServer : IMxAccessServer
{
private readonly Dictionary<string, int> handlesByAddress = new(StringComparer.Ordinal);
private int nextItemHandle = 100;
public List<string> AddedItems { get; } = new();
public int AdviseCount { get; private set; }
public List<int> AdvisedServerHandles { get; } = new();
public List<WriteRecord> Writes { get; } = new();
public int LastItemHandleFor(string itemAddress) => handlesByAddress[itemAddress];
public int Register(string clientName) => FakeServerHandle;
public void Unregister(int serverHandle)
{
}
public int AddItem(int serverHandle, string itemDefinition)
{
AddedItems.Add(itemDefinition);
int handle = nextItemHandle++;
handlesByAddress[itemDefinition] = handle;
return handle;
}
public int AddItem2(int serverHandle, string itemDefinition, string itemContext)
=> AddItem(serverHandle, itemDefinition);
public void RemoveItem(int serverHandle, int itemHandle)
{
}
public void Advise(int serverHandle, int itemHandle)
{
AdviseCount++;
AdvisedServerHandles.Add(serverHandle);
}
public void UnAdvise(int serverHandle, int itemHandle)
{
}
public void AdviseSupervisory(int serverHandle, int itemHandle)
{
}
public void Write(int serverHandle, int itemHandle, object? value, int userId)
=> Writes.Add(new WriteRecord(serverHandle, itemHandle, value, userId));
public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId)
{
}
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
{
}
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp)
{
}
internal sealed record WriteRecord(int ServerHandle, int ItemHandle, object? Value, int UserId);
}
}