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,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,123 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
public sealed class DriverTypeRegistryTests
|
||||
{
|
||||
private static DriverTypeMetadata SampleMetadata(
|
||||
string typeName = "Modbus",
|
||||
NamespaceKindCompatibility allowed = NamespaceKindCompatibility.Equipment,
|
||||
DriverTier tier = DriverTier.B) =>
|
||||
new(typeName, allowed,
|
||||
DriverConfigJsonSchema: "{\"type\": \"object\"}",
|
||||
DeviceConfigJsonSchema: "{\"type\": \"object\"}",
|
||||
TagConfigJsonSchema: "{\"type\": \"object\"}",
|
||||
Tier: tier);
|
||||
|
||||
[Fact]
|
||||
public void Register_ThenGet_RoundTrips()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
var metadata = SampleMetadata();
|
||||
|
||||
registry.Register(metadata);
|
||||
|
||||
registry.Get("Modbus").ShouldBe(metadata);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
[InlineData(DriverTier.C)]
|
||||
public void Register_Requires_NonNullTier(DriverTier tier)
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
var metadata = SampleMetadata(typeName: $"Driver-{tier}", tier: tier);
|
||||
|
||||
registry.Register(metadata);
|
||||
|
||||
registry.Get(metadata.TypeName).Tier.ShouldBe(tier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IsCaseInsensitive()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Galaxy"));
|
||||
|
||||
registry.Get("galaxy").ShouldNotBeNull();
|
||||
registry.Get("GALAXY").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_UnknownType_Throws()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<KeyNotFoundException>(() => registry.Get("UnregisteredType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_UnknownType_ReturnsNull()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
registry.TryGet("UnregisteredType").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_DuplicateType_Throws()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("Modbus")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_DuplicateTypeIsCaseInsensitive()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("modbus")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_ReturnsRegisteredTypes()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
registry.Register(SampleMetadata("S7"));
|
||||
registry.Register(SampleMetadata("Galaxy", NamespaceKindCompatibility.SystemPlatform));
|
||||
|
||||
var all = registry.All();
|
||||
|
||||
all.Count.ShouldBe(3);
|
||||
all.Select(m => m.TypeName).ShouldBe(new[] { "Modbus", "S7", "Galaxy" }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamespaceKindCompatibility_FlagsAreBitmask()
|
||||
{
|
||||
// Per decision #111 — driver types like OpcUaClient may be valid for multiple namespace kinds.
|
||||
var both = NamespaceKindCompatibility.Equipment | NamespaceKindCompatibility.SystemPlatform;
|
||||
|
||||
both.HasFlag(NamespaceKindCompatibility.Equipment).ShouldBeTrue();
|
||||
both.HasFlag(NamespaceKindCompatibility.SystemPlatform).ShouldBeTrue();
|
||||
both.HasFlag(NamespaceKindCompatibility.Simulated).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Get_RejectsEmptyTypeName(string? typeName)
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
Should.Throw<ArgumentException>(() => registry.Get(typeName!));
|
||||
}
|
||||
}
|
||||
@@ -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,71 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that <c>Core.Abstractions</c> stays a true contract project — it must not depend on
|
||||
/// any implementation type, any other OtOpcUa project, or anything beyond BCL + System types.
|
||||
/// Per <c>docs/v2/plan.md</c> decision #59 (Core.Abstractions internal-only for now; design as
|
||||
/// if public to minimize churn later).
|
||||
/// </summary>
|
||||
public sealed class InterfaceIndependenceTests
|
||||
{
|
||||
private static readonly Assembly Assembly = typeof(IDriver).Assembly;
|
||||
|
||||
[Fact]
|
||||
public void Assembly_HasNoReferencesOutsideBcl()
|
||||
{
|
||||
// Allowed reference assembly name prefixes — BCL + the assembly itself.
|
||||
var allowed = new[]
|
||||
{
|
||||
"System",
|
||||
"Microsoft.Win32",
|
||||
"netstandard",
|
||||
"mscorlib",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions",
|
||||
};
|
||||
|
||||
var referenced = Assembly.GetReferencedAssemblies();
|
||||
var disallowed = referenced
|
||||
.Where(r => !allowed.Any(a => r.Name!.StartsWith(a, StringComparison.Ordinal)))
|
||||
.ToList();
|
||||
|
||||
disallowed.ShouldBeEmpty(
|
||||
$"Core.Abstractions must reference only BCL/System assemblies. " +
|
||||
$"Found disallowed references: {string.Join(", ", disallowed.Select(a => a.Name))}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllPublicTypes_LiveInRootNamespace()
|
||||
{
|
||||
// Per the decision-#59 "design as if public" rule — no nested sub-namespaces; one flat surface.
|
||||
var publicTypes = Assembly.GetExportedTypes();
|
||||
var nonRoot = publicTypes
|
||||
.Where(t => t.Namespace != "ZB.MOM.WW.OtOpcUa.Core.Abstractions")
|
||||
.ToList();
|
||||
|
||||
nonRoot.ShouldBeEmpty(
|
||||
$"Core.Abstractions should expose all public types in the root namespace. " +
|
||||
$"Found types in other namespaces: {string.Join(", ", nonRoot.Select(t => $"{t.FullName}"))}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(IDriver))]
|
||||
[InlineData(typeof(ITagDiscovery))]
|
||||
[InlineData(typeof(IReadable))]
|
||||
[InlineData(typeof(IWritable))]
|
||||
[InlineData(typeof(ISubscribable))]
|
||||
[InlineData(typeof(IAlarmSource))]
|
||||
[InlineData(typeof(IHistoryProvider))]
|
||||
[InlineData(typeof(IRediscoverable))]
|
||||
[InlineData(typeof(IHostConnectivityProbe))]
|
||||
[InlineData(typeof(IDriverConfigEditor))]
|
||||
[InlineData(typeof(IAddressSpaceBuilder))]
|
||||
public void EveryCapabilityInterface_IsPublic(Type type)
|
||||
{
|
||||
type.IsPublic.ShouldBeTrue($"{type.Name} must be public — drivers in separate assemblies implement it.");
|
||||
type.IsInterface.ShouldBeTrue($"{type.Name} must be an interface, not a class.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PollGroupEngineTests
|
||||
{
|
||||
private sealed class FakeSource
|
||||
{
|
||||
public ConcurrentDictionary<string, object?> Values { get; } = new();
|
||||
public int ReadCount;
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> refs, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref ReadCount);
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> snapshots = refs
|
||||
.Select(r => Values.TryGetValue(r, out var v)
|
||||
? new DataValueSnapshot(v, 0u, now, now)
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, now))
|
||||
.ToList();
|
||||
return Task.FromResult(snapshots);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initial_poll_force_raises_every_subscribed_tag()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["A"] = 1;
|
||||
src.Values["B"] = "hello";
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle h, string r, DataValueSnapshot s)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)));
|
||||
|
||||
var handle = engine.Subscribe(["A", "B"], TimeSpan.FromMilliseconds(200));
|
||||
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.Select(e => e.r).ShouldBe(["A", "B"], ignoreOrder: true);
|
||||
engine.Unsubscribe(handle).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_value_raises_only_once()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 42;
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await Task.Delay(500);
|
||||
engine.Unsubscribe(handle);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Value_change_raises_new_event()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
src.Values["X"] = 2;
|
||||
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
engine.Unsubscribe(handle);
|
||||
events.Last().Item3.Value.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_halts_the_loop()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
engine.Unsubscribe(handle).ShouldBeTrue();
|
||||
var afterUnsub = events.Count;
|
||||
|
||||
src.Values["X"] = 999;
|
||||
await Task.Delay(400);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interval_below_floor_is_clamped()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)),
|
||||
minInterval: TimeSpan.FromMilliseconds(200));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(5));
|
||||
await Task.Delay(300);
|
||||
engine.Unsubscribe(handle);
|
||||
|
||||
// 300 ms window, 200 ms floor, stable value → initial push + at most 1 extra poll.
|
||||
// With zero changes only the initial-data push fires.
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_subscriptions_are_independent()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["A"] = 1;
|
||||
src.Values["B"] = 2;
|
||||
|
||||
var a = new ConcurrentQueue<string>();
|
||||
var b = new ConcurrentQueue<string>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) =>
|
||||
{
|
||||
if (r == "A") a.Enqueue(r);
|
||||
else if (r == "B") b.Enqueue(r);
|
||||
});
|
||||
|
||||
var ha = engine.Subscribe(["A"], TimeSpan.FromMilliseconds(100));
|
||||
var hb = engine.Subscribe(["B"], TimeSpan.FromMilliseconds(100));
|
||||
|
||||
await WaitForAsync(() => a.Count >= 1 && b.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
engine.Unsubscribe(ha);
|
||||
var aCount = a.Count;
|
||||
src.Values["B"] = 77;
|
||||
await WaitForAsync(() => b.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
a.Count.ShouldBe(aCount);
|
||||
b.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
engine.Unsubscribe(hb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_exception_does_not_crash_loop()
|
||||
{
|
||||
var throwCount = 0;
|
||||
var readCount = 0;
|
||||
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.Increment(ref readCount) <= 2)
|
||||
{
|
||||
Interlocked.Increment(ref throwCount);
|
||||
throw new InvalidOperationException("boom");
|
||||
}
|
||||
var now = DateTime.UtcNow;
|
||||
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
|
||||
refs.Select(r => new DataValueSnapshot(1, 0u, now, now)).ToList());
|
||||
}
|
||||
|
||||
var events = new ConcurrentQueue<string>();
|
||||
await using var engine = new PollGroupEngine(Reader,
|
||||
(h, r, s) => events.Enqueue(r));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
engine.Unsubscribe(handle);
|
||||
|
||||
throwCount.ShouldBe(2);
|
||||
events.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_unknown_handle_returns_false()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync, (_, _, _) => { });
|
||||
|
||||
var foreign = new DummyHandle();
|
||||
engine.Unsubscribe(foreign).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveSubscriptionCount_tracks_lifecycle()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync, (_, _, _) => { });
|
||||
|
||||
engine.ActiveSubscriptionCount.ShouldBe(0);
|
||||
var h1 = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(200));
|
||||
var h2 = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(200));
|
||||
engine.ActiveSubscriptionCount.ShouldBe(2);
|
||||
|
||||
engine.Unsubscribe(h1);
|
||||
engine.ActiveSubscriptionCount.ShouldBe(1);
|
||||
engine.Unsubscribe(h2);
|
||||
engine.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_cancels_all_subscriptions()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue(r));
|
||||
|
||||
_ = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
_ = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
await engine.DisposeAsync();
|
||||
engine.ActiveSubscriptionCount.ShouldBe(0);
|
||||
|
||||
var afterDispose = events.Count;
|
||||
await Task.Delay(300);
|
||||
// After dispose no more events — everything is cancelled.
|
||||
events.Count.ShouldBe(afterDispose);
|
||||
}
|
||||
|
||||
private sealed record DummyHandle : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "dummy";
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user