Adds alarm_ack.md documenting the two-way acknowledge flow (OPC UA client writes AckMsg, Galaxy confirms via Acked data change). Includes external code review fixes for subscriptions and node manager, and removes stale plan files now superseded by component documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
230 lines
8.4 KiB
C#
230 lines
8.4 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
|
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
|
using ZB.MOM.WW.LmxOpcUa.Host.MxAccess;
|
|
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
|
|
|
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
|
|
{
|
|
/// <summary>
|
|
/// Verifies how the MXAccess client manages persistent subscriptions, reconnect replay, and probe-tag behavior.
|
|
/// </summary>
|
|
public class MxAccessClientSubscriptionTests : IDisposable
|
|
{
|
|
private readonly StaComThread _staThread;
|
|
private readonly FakeMxProxy _proxy;
|
|
private readonly PerformanceMetrics _metrics;
|
|
private readonly MxAccessClient _client;
|
|
|
|
/// <summary>
|
|
/// Initializes the subscription test fixture with a fake runtime proxy and STA thread.
|
|
/// </summary>
|
|
public MxAccessClientSubscriptionTests()
|
|
{
|
|
_staThread = new StaComThread();
|
|
_staThread.Start();
|
|
_proxy = new FakeMxProxy();
|
|
_metrics = new PerformanceMetrics();
|
|
_client = new MxAccessClient(_staThread, _proxy, new MxAccessConfiguration(), _metrics);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes the subscription test fixture and its supporting resources.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
_client.Dispose();
|
|
_staThread.Dispose();
|
|
_metrics.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that subscribing creates a runtime item, advises it, and increments the active subscription count.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Subscribe_CreatesItemAndAdvises()
|
|
{
|
|
await _client.ConnectAsync();
|
|
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
|
|
|
_proxy.Items.Count.ShouldBeGreaterThan(0);
|
|
_proxy.AdvisedItems.Count.ShouldBeGreaterThan(0);
|
|
_client.ActiveSubscriptionCount.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that subscribing to the same address twice reuses the existing runtime item.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Subscribe_SameAddressTwice_ReusesExistingRuntimeItem()
|
|
{
|
|
await _client.ConnectAsync();
|
|
|
|
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
|
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
|
|
|
_client.ActiveSubscriptionCount.ShouldBe(1);
|
|
_proxy.Items.Values.Count(v => v == "TestTag.Attr").ShouldBe(1);
|
|
|
|
await _client.UnsubscribeAsync("TestTag.Attr");
|
|
|
|
_proxy.Items.Values.ShouldNotContain("TestTag.Attr");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that unsubscribing clears the active subscription count after a tag was previously monitored.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Unsubscribe_RemovesItemAndUnadvises()
|
|
{
|
|
await _client.ConnectAsync();
|
|
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
|
await _client.UnsubscribeAsync("TestTag.Attr");
|
|
|
|
_client.ActiveSubscriptionCount.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that runtime data changes are delivered to the per-subscription callback.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OnDataChange_InvokesCallback()
|
|
{
|
|
await _client.ConnectAsync();
|
|
|
|
Vtq? received = null;
|
|
await _client.SubscribeAsync("TestTag.Attr", (addr, vtq) => received = vtq);
|
|
|
|
_proxy.SimulateDataChangeByAddress("TestTag.Attr", 42, 192);
|
|
|
|
received.ShouldNotBeNull();
|
|
received.Value.Value.ShouldBe(42);
|
|
received.Value.Quality.ShouldBe(Quality.Good);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that runtime data changes are also delivered to the client's global tag-change event.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OnDataChange_InvokesGlobalHandler()
|
|
{
|
|
await _client.ConnectAsync();
|
|
|
|
string? globalAddr = null;
|
|
_client.OnTagValueChanged += (addr, vtq) => globalAddr = addr;
|
|
|
|
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
|
_proxy.SimulateDataChangeByAddress("TestTag.Attr", "hello", 192);
|
|
|
|
globalAddr.ShouldBe("TestTag.Attr");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that stored subscriptions are replayed after reconnect so live updates resume automatically.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StoredSubscriptions_ReplayedAfterReconnect()
|
|
{
|
|
await _client.ConnectAsync();
|
|
var callbackInvoked = false;
|
|
await _client.SubscribeAsync("TestTag.Attr", (_, _) => callbackInvoked = true);
|
|
|
|
// Reconnect
|
|
await _client.ReconnectAsync();
|
|
|
|
// After reconnect, subscription should be replayed
|
|
_client.ActiveSubscriptionCount.ShouldBe(1);
|
|
|
|
// Simulate data change on the re-subscribed item
|
|
_proxy.SimulateDataChangeByAddress("TestTag.Attr", "value", 192);
|
|
callbackInvoked.ShouldBe(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that one-shot reads do not remove persistent subscriptions when the client reconnects.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OneShotRead_DoesNotRemovePersistentSubscription_OnReconnect()
|
|
{
|
|
await _client.ConnectAsync();
|
|
var callbackInvoked = false;
|
|
await _client.SubscribeAsync("TestTag.Attr", (_, _) => callbackInvoked = true);
|
|
|
|
var readTask = _client.ReadAsync("TestTag.Attr");
|
|
await Task.Delay(50);
|
|
_proxy.SimulateDataChangeByAddress("TestTag.Attr", 42, 192);
|
|
(await readTask).Value.ShouldBe(42);
|
|
callbackInvoked = false;
|
|
|
|
await _client.ReconnectAsync();
|
|
|
|
_proxy.SimulateDataChangeByAddress("TestTag.Attr", "after_reconnect", 192);
|
|
callbackInvoked.ShouldBe(true);
|
|
_client.ActiveSubscriptionCount.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that transient writes do not prevent later removal of a persistent subscription.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OneShotWrite_DoesNotBreakPersistentUnsubscribe()
|
|
{
|
|
await _client.ConnectAsync();
|
|
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
|
_proxy.Items.Values.ShouldContain("TestTag.Attr");
|
|
|
|
var writeResult = await _client.WriteAsync("TestTag.Attr", 7);
|
|
writeResult.ShouldBe(true);
|
|
|
|
await _client.UnsubscribeAsync("TestTag.Attr");
|
|
|
|
_client.ActiveSubscriptionCount.ShouldBe(0);
|
|
_proxy.Items.Values.ShouldNotContain("TestTag.Attr");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that the configured probe tag is subscribed during connect so connectivity monitoring can start immediately.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ProbeTag_SubscribedOnConnect()
|
|
{
|
|
var proxy = new FakeMxProxy();
|
|
var config = new MxAccessConfiguration { ProbeTag = "TestProbe" };
|
|
var client = new MxAccessClient(_staThread, proxy, config, _metrics);
|
|
|
|
await client.ConnectAsync();
|
|
|
|
// Probe tag should be subscribed (present in proxy items)
|
|
proxy.Items.Values.ShouldContain("TestProbe");
|
|
client.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Confirms that the probe tag cannot be unsubscribed accidentally because it is reserved for connection monitoring.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ProbeTag_ProtectedFromUnsubscribe()
|
|
{
|
|
var proxy = new FakeMxProxy();
|
|
var config = new MxAccessConfiguration { ProbeTag = "TestProbe" };
|
|
var client = new MxAccessClient(_staThread, proxy, config, _metrics);
|
|
|
|
await client.ConnectAsync();
|
|
proxy.Items.Values.ShouldContain("TestProbe");
|
|
|
|
// Attempt to unsubscribe the probe tag — should be protected
|
|
await client.UnsubscribeAsync("TestProbe");
|
|
|
|
// Probe should still be in the proxy items (not removed)
|
|
proxy.Items.Values.ShouldContain("TestProbe");
|
|
client.Dispose();
|
|
}
|
|
}
|
|
}
|