docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
This commit is contained in:
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class AlarmsCommandTests
|
||||
{
|
||||
/// <summary>Verifies that Execute subscribes to alarms.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_SubscribesToAlarms()
|
||||
{
|
||||
@@ -31,6 +32,7 @@ public class AlarmsCommandTests
|
||||
fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute with node passes source node ID.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_WithNode_PassesSourceNodeId()
|
||||
{
|
||||
@@ -55,6 +57,7 @@ public class AlarmsCommandTests
|
||||
fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Identifier.ShouldBe("AlarmSource");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute with refresh requests condition refresh.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_WithRefresh_RequestsConditionRefresh()
|
||||
{
|
||||
@@ -79,6 +82,7 @@ public class AlarmsCommandTests
|
||||
output.ShouldContain("Condition refresh requested.");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that refresh failure prints error.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_RefreshFailure_PrintsError()
|
||||
{
|
||||
@@ -105,6 +109,7 @@ public class AlarmsCommandTests
|
||||
output.ShouldContain("Condition refresh not supported:");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute unsubscribes on cancellation.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_UnsubscribesOnCancellation()
|
||||
{
|
||||
@@ -126,6 +131,7 @@ public class AlarmsCommandTests
|
||||
fakeService.UnsubscribeAlarmsCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute disconnects in finally block.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsInFinally()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class BrowseCommandTests
|
||||
{
|
||||
/// <summary>Verifies that Execute prints browse results correctly.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_PrintsBrowseResults()
|
||||
{
|
||||
@@ -35,6 +36,7 @@ public class BrowseCommandTests
|
||||
output.ShouldContain("[Method] Method1 (NodeId: ns=2;s=Meth1)");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute browses from the specified node ID.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_BrowsesFromSpecifiedNode()
|
||||
{
|
||||
@@ -57,6 +59,7 @@ public class BrowseCommandTests
|
||||
fakeService.BrowseNodeIds[0]!.Identifier.ShouldBe("StartNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute browses from null node when not specified.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DefaultBrowsesFromNull()
|
||||
{
|
||||
@@ -77,6 +80,7 @@ public class BrowseCommandTests
|
||||
fakeService.BrowseNodeIds[0].ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute browses only a single level when not recursive.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_NonRecursive_BrowsesSingleLevel()
|
||||
{
|
||||
@@ -101,6 +105,7 @@ public class BrowseCommandTests
|
||||
fakeService.BrowseNodeIds.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute browses child nodes when recursive flag is set.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_Recursive_BrowsesChildren()
|
||||
{
|
||||
@@ -123,6 +128,7 @@ public class BrowseCommandTests
|
||||
fakeService.BrowseNodeIds.Count.ShouldBeGreaterThan(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute disconnects and disposes in the finally block.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsInFinally()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class CommandBaseTests
|
||||
{
|
||||
/// <summary>Verifies that common options map to connection settings correctly.</summary>
|
||||
[Fact]
|
||||
public async Task CommonOptions_MapToConnectionSettings_Correctly()
|
||||
{
|
||||
@@ -37,6 +38,7 @@ public class CommandBaseTests
|
||||
settings.AutoAcceptCertificates.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that encrypt option maps to SignAndEncrypt.</summary>
|
||||
[Fact]
|
||||
public async Task SecurityOption_Encrypt_MapsToSignAndEncrypt()
|
||||
{
|
||||
@@ -54,6 +56,7 @@ public class CommandBaseTests
|
||||
fakeService.LastConnectionSettings!.SecurityMode.ShouldBe(SecurityMode.SignAndEncrypt);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that none option maps to None.</summary>
|
||||
[Fact]
|
||||
public async Task SecurityOption_None_MapsToNone()
|
||||
{
|
||||
@@ -71,6 +74,7 @@ public class CommandBaseTests
|
||||
fakeService.LastConnectionSettings!.SecurityMode.ShouldBe(SecurityMode.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no failover URLs results in null FailoverUrls.</summary>
|
||||
[Fact]
|
||||
public async Task NoFailoverUrls_FailoverUrlsIsNull()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
/// </summary>
|
||||
public class CommandRangeValidationTests
|
||||
{
|
||||
/// <summary>Verifies that BrowseCommand rejects negative depth values with a command exception.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseCommand_NegativeDepth_ThrowsCommandException()
|
||||
{
|
||||
@@ -28,6 +29,7 @@ public class CommandRangeValidationTests
|
||||
ex.Message.ShouldContain("--depth");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BrowseCommand rejects zero depth values with a command exception.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseCommand_ZeroDepth_ThrowsCommandException()
|
||||
{
|
||||
@@ -44,6 +46,7 @@ public class CommandRangeValidationTests
|
||||
ex.Message.ShouldContain("--depth");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeCommand rejects zero interval values with a command exception.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_ZeroInterval_ThrowsCommandException()
|
||||
{
|
||||
@@ -61,6 +64,7 @@ public class CommandRangeValidationTests
|
||||
ex.Message.ShouldContain("--interval");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeCommand rejects negative interval values with a command exception.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_NegativeInterval_ThrowsCommandException()
|
||||
{
|
||||
@@ -77,6 +81,7 @@ public class CommandRangeValidationTests
|
||||
await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeCommand in recursive mode rejects zero max depth with a command exception.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_RecursiveZeroMaxDepth_ThrowsCommandException()
|
||||
{
|
||||
@@ -96,6 +101,7 @@ public class CommandRangeValidationTests
|
||||
ex.Message.ShouldContain("--max-depth");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeCommand rejects negative duration values with a command exception.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_NegativeDuration_ThrowsCommandException()
|
||||
{
|
||||
@@ -112,6 +118,7 @@ public class CommandRangeValidationTests
|
||||
await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AlarmsCommand rejects zero interval values with a command exception.</summary>
|
||||
[Fact]
|
||||
public async Task AlarmsCommand_ZeroInterval_ThrowsCommandException()
|
||||
{
|
||||
@@ -128,6 +135,7 @@ public class CommandRangeValidationTests
|
||||
ex.Message.ShouldContain("--interval");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HistoryReadCommand rejects negative max values with a command exception.</summary>
|
||||
[Fact]
|
||||
public async Task HistoryReadCommand_NegativeMax_ThrowsCommandException()
|
||||
{
|
||||
@@ -145,6 +153,7 @@ public class CommandRangeValidationTests
|
||||
ex.Message.ShouldContain("--max");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HistoryReadCommand rejects zero interval values with a command exception.</summary>
|
||||
[Fact]
|
||||
public async Task HistoryReadCommand_ZeroInterval_ThrowsCommandException()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class ConnectCommandTests
|
||||
{
|
||||
/// <summary>Verifies that execute prints connection info.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_PrintsConnectionInfo()
|
||||
{
|
||||
@@ -38,6 +39,7 @@ public class ConnectCommandTests
|
||||
output.ShouldContain("Connection successful.");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that execute calls connect and disconnect.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_CallsConnectAndDisconnect()
|
||||
{
|
||||
@@ -56,6 +58,7 @@ public class ConnectCommandTests
|
||||
fakeService.DisposeCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that execute disconnects on error.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsOnError()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
/// </summary>
|
||||
public class EventHandlerLifecycleTests
|
||||
{
|
||||
/// <summary>Verifies that SubscribeCommand detaches the DataChanged event handler after exit.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_AfterExit_DataChangedEventHasNoSubscribers()
|
||||
{
|
||||
@@ -35,6 +36,7 @@ public class EventHandlerLifecycleTests
|
||||
"SubscribeCommand must detach its DataChanged handler before returning.");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AlarmsCommand detaches the AlarmEvent handler after exit.</summary>
|
||||
[Fact]
|
||||
public async Task AlarmsCommand_AfterExit_AlarmEventHasNoSubscribers()
|
||||
{
|
||||
|
||||
@@ -12,27 +12,55 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
{
|
||||
// Track calls
|
||||
/// <summary>Gets a value indicating whether ConnectAsync was called.</summary>
|
||||
public bool ConnectCalled { get; private set; }
|
||||
|
||||
/// <summary>Gets the connection settings from the most recent ConnectAsync call.</summary>
|
||||
public ConnectionSettings? LastConnectionSettings { get; private set; }
|
||||
|
||||
/// <summary>Gets a value indicating whether DisconnectAsync was called.</summary>
|
||||
public bool DisconnectCalled { get; private set; }
|
||||
|
||||
/// <summary>Gets a value indicating whether Dispose was called.</summary>
|
||||
public bool DisposeCalled { get; private set; }
|
||||
|
||||
/// <summary>Gets the list of node IDs passed to ReadValueAsync calls.</summary>
|
||||
public List<NodeId> ReadNodeIds { get; } = [];
|
||||
|
||||
/// <summary>Gets the list of (NodeId, value) pairs passed to WriteValueAsync calls.</summary>
|
||||
public List<(NodeId NodeId, object Value)> WriteValues { get; } = [];
|
||||
|
||||
/// <summary>Gets the list of parent node IDs passed to BrowseAsync calls.</summary>
|
||||
public List<NodeId?> BrowseNodeIds { get; } = [];
|
||||
|
||||
/// <summary>Gets the list of (NodeId, intervalMs) pairs from SubscribeAsync calls.</summary>
|
||||
public List<(NodeId NodeId, int IntervalMs)> SubscribeCalls { get; } = [];
|
||||
|
||||
/// <summary>Gets the list of node IDs passed to UnsubscribeAsync calls.</summary>
|
||||
public List<NodeId> UnsubscribeCalls { get; } = [];
|
||||
|
||||
/// <summary>Gets the list of (SourceNodeId, intervalMs) pairs from SubscribeAlarmsAsync calls.</summary>
|
||||
public List<(NodeId? SourceNodeId, int IntervalMs)> SubscribeAlarmsCalls { get; } = [];
|
||||
|
||||
/// <summary>Gets a value indicating whether UnsubscribeAlarmsAsync was called.</summary>
|
||||
public bool UnsubscribeAlarmsCalled { get; private set; }
|
||||
|
||||
/// <summary>Gets a value indicating whether RequestConditionRefreshAsync was called.</summary>
|
||||
public bool RequestConditionRefreshCalled { get; private set; }
|
||||
|
||||
/// <summary>Gets the list of (NodeId, start, end, maxValues) tuples from HistoryReadRawAsync calls.</summary>
|
||||
public List<(NodeId NodeId, DateTime Start, DateTime End, int MaxValues)> HistoryReadRawCalls { get; } = [];
|
||||
|
||||
/// <summary>Gets the list of history read aggregate call parameters.</summary>
|
||||
public List<(NodeId NodeId, DateTime Start, DateTime End, AggregateType Aggregate, double IntervalMs)>
|
||||
HistoryReadAggregateCalls { get; } =
|
||||
[];
|
||||
|
||||
/// <summary>Gets a value indicating whether GetRedundancyInfoAsync was called.</summary>
|
||||
public bool GetRedundancyInfoCalled { get; private set; }
|
||||
|
||||
// Configurable results
|
||||
/// <summary>Gets or sets the connection info returned by ConnectAsync.</summary>
|
||||
public ConnectionInfo ConnectionInfoResult { get; set; } = new(
|
||||
"opc.tcp://localhost:4840",
|
||||
"TestServer",
|
||||
@@ -41,14 +69,17 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
"session-1",
|
||||
"TestSession");
|
||||
|
||||
/// <summary>Gets or sets the data value returned by ReadValueAsync.</summary>
|
||||
public DataValue ReadValueResult { get; set; } = new(
|
||||
new Variant(42),
|
||||
StatusCodes.Good,
|
||||
DateTime.UtcNow,
|
||||
DateTime.UtcNow);
|
||||
|
||||
/// <summary>Gets or sets the status code returned by WriteValueAsync.</summary>
|
||||
public StatusCode WriteStatusCodeResult { get; set; } = StatusCodes.Good;
|
||||
|
||||
/// <summary>Gets or sets the browse results returned by BrowseAsync.</summary>
|
||||
public IReadOnlyList<BrowseResult> BrowseResults { get; set; } = new List<BrowseResult>
|
||||
{
|
||||
new("ns=2;s=Node1", "Node1", "Object", true),
|
||||
@@ -63,19 +94,30 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
/// </summary>
|
||||
public Dictionary<string, IReadOnlyList<BrowseResult>> BrowseResultsByParent { get; } = new();
|
||||
|
||||
/// <summary>Gets or sets the history read result returned by HistoryReadRawAsync and HistoryReadAggregateAsync.</summary>
|
||||
public IReadOnlyList<DataValue> HistoryReadResult { get; set; } = new List<DataValue>
|
||||
{
|
||||
new(new Variant(10.0), StatusCodes.Good, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow),
|
||||
new(new Variant(20.0), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)
|
||||
};
|
||||
|
||||
/// <summary>Gets or sets the redundancy info returned by GetRedundancyInfoAsync.</summary>
|
||||
public RedundancyInfo RedundancyInfoResult { get; set; } = new(
|
||||
"Warm", 200, ["urn:server1", "urn:server2"], "urn:app:test");
|
||||
|
||||
/// <summary>Gets or sets the exception thrown by ConnectAsync.</summary>
|
||||
public Exception? ConnectException { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the exception thrown by ReadValueAsync.</summary>
|
||||
public Exception? ReadException { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the exception thrown by WriteValueAsync.</summary>
|
||||
public Exception? WriteException { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the exception thrown by RequestConditionRefreshAsync.</summary>
|
||||
public Exception? ConditionRefreshException { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the exception thrown by SubscribeAsync.</summary>
|
||||
public Exception? SubscribeException { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -218,18 +260,24 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
|
||||
/// <summary>Raises the DataChanged event for testing subscribe commands.</summary>
|
||||
/// <param name="nodeId">The node ID string that changed.</param>
|
||||
/// <param name="value">The new data value.</param>
|
||||
public void RaiseDataChanged(string nodeId, DataValue value)
|
||||
{
|
||||
DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
|
||||
}
|
||||
|
||||
/// <summary>Raises the AlarmEvent for testing alarm commands.</summary>
|
||||
/// <param name="args">The alarm event arguments.</param>
|
||||
public void RaiseAlarmEvent(AlarmEventArgs args)
|
||||
{
|
||||
AlarmEvent?.Invoke(this, args);
|
||||
}
|
||||
|
||||
/// <summary>Raises the ConnectionStateChanged event for testing.</summary>
|
||||
/// <param name="oldState">The previous connection state.</param>
|
||||
/// <param name="newState">The new connection state.</param>
|
||||
/// <param name="endpointUrl">The endpoint URL.</param>
|
||||
public void RaiseConnectionStateChanged(ConnectionState oldState, ConnectionState newState, string endpointUrl)
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
|
||||
|
||||
@@ -9,11 +9,14 @@ public sealed class FakeOpcUaClientServiceFactory : IOpcUaClientServiceFactory
|
||||
{
|
||||
private readonly FakeOpcUaClientService _service;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="FakeOpcUaClientServiceFactory"/> class.</summary>
|
||||
/// <param name="service">The fake OPC UA client service to return.</param>
|
||||
public FakeOpcUaClientServiceFactory(FakeOpcUaClientService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// <summary>Creates and returns the fake OPC UA client service.</summary>
|
||||
public IOpcUaClientService Create()
|
||||
{
|
||||
return _service;
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class HistoryReadCommandTests
|
||||
{
|
||||
/// <summary>Verifies RawRead execution prints values.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_RawRead_PrintsValues()
|
||||
{
|
||||
@@ -42,6 +43,7 @@ public class HistoryReadCommandTests
|
||||
output.ShouldContain("2 values returned.");
|
||||
}
|
||||
|
||||
/// <summary>Verifies RawRead execution calls HistoryReadRaw.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_RawRead_CallsHistoryReadRaw()
|
||||
{
|
||||
@@ -62,6 +64,7 @@ public class HistoryReadCommandTests
|
||||
fakeService.HistoryReadRawCalls[0].NodeId.Identifier.ShouldBe("HistNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies AggregateRead execution calls HistoryReadAggregate.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_AggregateRead_CallsHistoryReadAggregate()
|
||||
{
|
||||
@@ -83,6 +86,7 @@ public class HistoryReadCommandTests
|
||||
fakeService.HistoryReadAggregateCalls[0].IntervalMs.ShouldBe(60000);
|
||||
}
|
||||
|
||||
/// <summary>Verifies AggregateRead execution prints aggregate info.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_AggregateRead_PrintsAggregateInfo()
|
||||
{
|
||||
@@ -104,6 +108,7 @@ public class HistoryReadCommandTests
|
||||
output.ShouldContain("7200000");
|
||||
}
|
||||
|
||||
/// <summary>Verifies invalid aggregate throws CommandException.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_InvalidAggregate_ThrowsCommandException()
|
||||
{
|
||||
@@ -123,6 +128,7 @@ public class HistoryReadCommandTests
|
||||
async () => await command.ExecuteAsync(console));
|
||||
}
|
||||
|
||||
/// <summary>Verifies disconnect is called in finally block.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsInFinally()
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
/// </summary>
|
||||
public class InputValidationErrorsTests
|
||||
{
|
||||
/// <summary>Verifies that HistoryReadCommand with invalid start time throws CommandException.</summary>
|
||||
[Fact]
|
||||
public async Task HistoryReadCommand_InvalidStartTime_ThrowsCommandException()
|
||||
{
|
||||
@@ -30,6 +31,7 @@ public class InputValidationErrorsTests
|
||||
ex.Message.ShouldContain("--start");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HistoryReadCommand with invalid end time throws CommandException.</summary>
|
||||
[Fact]
|
||||
public async Task HistoryReadCommand_InvalidEndTime_ThrowsCommandException()
|
||||
{
|
||||
@@ -47,6 +49,7 @@ public class InputValidationErrorsTests
|
||||
ex.Message.ShouldContain("--end");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HistoryReadCommand with invalid aggregate throws CommandException.</summary>
|
||||
[Fact]
|
||||
public async Task HistoryReadCommand_InvalidAggregate_ThrowsCommandException()
|
||||
{
|
||||
@@ -64,6 +67,7 @@ public class InputValidationErrorsTests
|
||||
ex.Message.ShouldContain("aggregate", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadCommand with invalid node ID throws CommandException.</summary>
|
||||
[Fact]
|
||||
public async Task ReadCommand_InvalidNodeId_ThrowsCommandException()
|
||||
{
|
||||
@@ -80,6 +84,7 @@ public class InputValidationErrorsTests
|
||||
ex.Message.ShouldContain("node", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeCommand with invalid node ID throws CommandException.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_InvalidNodeId_ThrowsCommandException()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
/// </summary>
|
||||
public class LoggerLifecycleTests
|
||||
{
|
||||
/// <summary>Verifies that ConfigureLogging disposes previous logger before reassigning.</summary>
|
||||
[Fact]
|
||||
public async Task ConfigureLogging_DisposesPreviousLogger_BeforeReassigning()
|
||||
{
|
||||
@@ -48,8 +49,12 @@ public class LoggerLifecycleTests
|
||||
|
||||
private sealed class DisposeTrackingSink : ILogEventSink, IDisposable
|
||||
{
|
||||
/// <summary>Gets a value indicating whether the sink has been disposed.</summary>
|
||||
public bool Disposed { get; private set; }
|
||||
/// <summary>Emits a log event.</summary>
|
||||
/// <param name="logEvent">The log event to emit.</param>
|
||||
public void Emit(LogEvent logEvent) { }
|
||||
/// <summary>Disposes the sink and marks it as disposed.</summary>
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,24 +7,28 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class NodeIdParserTests
|
||||
{
|
||||
/// <summary>Verifies that Parse returns null for null input.</summary>
|
||||
[Fact]
|
||||
public void Parse_NullInput_ReturnsNull()
|
||||
{
|
||||
NodeIdParser.Parse(null).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse returns null for empty string input.</summary>
|
||||
[Fact]
|
||||
public void Parse_EmptyString_ReturnsNull()
|
||||
{
|
||||
NodeIdParser.Parse("").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse returns null for whitespace-only input.</summary>
|
||||
[Fact]
|
||||
public void Parse_WhitespaceOnly_ReturnsNull()
|
||||
{
|
||||
NodeIdParser.Parse(" ").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse correctly parses standard NodeId format (ns=2;s=MyNode).</summary>
|
||||
[Fact]
|
||||
public void Parse_StandardStringFormat_ReturnsNodeId()
|
||||
{
|
||||
@@ -34,6 +38,7 @@ public class NodeIdParserTests
|
||||
result.Identifier.ShouldBe("MyNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse correctly parses numeric NodeId format (i=85).</summary>
|
||||
[Fact]
|
||||
public void Parse_NumericFormat_ReturnsNodeId()
|
||||
{
|
||||
@@ -42,6 +47,7 @@ public class NodeIdParserTests
|
||||
result.IdType.ShouldBe(IdType.Numeric);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse treats bare numeric input as namespace 0 numeric NodeId.</summary>
|
||||
[Fact]
|
||||
public void Parse_BareNumeric_ReturnsNamespace0NumericNodeId()
|
||||
{
|
||||
@@ -51,6 +57,7 @@ public class NodeIdParserTests
|
||||
result.Identifier.ShouldBe((uint)85);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse trims whitespace padding from input.</summary>
|
||||
[Fact]
|
||||
public void Parse_WithWhitespacePadding_Trims()
|
||||
{
|
||||
@@ -59,24 +66,28 @@ public class NodeIdParserTests
|
||||
result.Identifier.ShouldBe("MyNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse throws FormatException for invalid input.</summary>
|
||||
[Fact]
|
||||
public void Parse_InvalidFormat_ThrowsFormatException()
|
||||
{
|
||||
Should.Throw<FormatException>(() => NodeIdParser.Parse("not-a-node-id"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ParseRequired throws ArgumentException for null input.</summary>
|
||||
[Fact]
|
||||
public void ParseRequired_NullInput_ThrowsArgumentException()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => NodeIdParser.ParseRequired(null));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ParseRequired throws ArgumentException for empty input.</summary>
|
||||
[Fact]
|
||||
public void ParseRequired_EmptyInput_ThrowsArgumentException()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => NodeIdParser.ParseRequired(""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ParseRequired returns a NodeId for valid input.</summary>
|
||||
[Fact]
|
||||
public void ParseRequired_ValidInput_ReturnsNodeId()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class ReadCommandTests
|
||||
{
|
||||
/// <summary>Verifies that execute prints the read value.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_PrintsReadValue()
|
||||
{
|
||||
@@ -39,6 +40,7 @@ public class ReadCommandTests
|
||||
output.ShouldContain("Server Time:");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that execute calls read value with correct node ID.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_CallsReadValueWithCorrectNodeId()
|
||||
{
|
||||
@@ -57,6 +59,7 @@ public class ReadCommandTests
|
||||
fakeService.ReadNodeIds[0].Identifier.ShouldBe("MyVariable");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that execute disconnects in finally.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsInFinally()
|
||||
{
|
||||
@@ -75,6 +78,7 @@ public class ReadCommandTests
|
||||
fakeService.DisposeCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that execute disconnects even on read error.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsEvenOnReadError()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class RedundancyCommandTests
|
||||
{
|
||||
/// <summary>Verifies that Execute prints redundancy information correctly.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_PrintsRedundancyInfo()
|
||||
{
|
||||
@@ -34,6 +35,7 @@ public class RedundancyCommandTests
|
||||
output.ShouldContain("Application URI: urn:app:myserver");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute omits the Server URIs section when none are present.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_NoServerUris_OmitsUriSection()
|
||||
{
|
||||
@@ -58,6 +60,7 @@ public class RedundancyCommandTests
|
||||
output.ShouldContain("Application URI: urn:app:standalone");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute calls GetRedundancyInfo on the service.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_CallsGetRedundancyInfo()
|
||||
{
|
||||
@@ -74,6 +77,7 @@ public class RedundancyCommandTests
|
||||
fakeService.GetRedundancyInfoCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute disconnects and disposes in the finally block.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsInFinally()
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
/// </summary>
|
||||
public class SubscribeCommandSummaryTests
|
||||
{
|
||||
/// <summary>Verifies that nodes with no updates are counted separately from suspects.</summary>
|
||||
[Fact]
|
||||
public async Task Summary_NodeWithNoUpdate_IsCountedAsNeverNotAsNeverWentBad()
|
||||
{
|
||||
@@ -39,6 +40,7 @@ public class SubscribeCommandSummaryTests
|
||||
output.ShouldContain("--- Nodes that never received an update at all ---");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nodes with only good values are counted as never went bad.</summary>
|
||||
[Fact]
|
||||
public async Task Summary_NodeReceivedOnlyGoodValues_IsCountedAsNeverWentBad()
|
||||
{
|
||||
@@ -70,6 +72,7 @@ public class SubscribeCommandSummaryTests
|
||||
output.ShouldContain("No update received at all: 0");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nodes with bad values are counted as ever went bad.</summary>
|
||||
[Fact]
|
||||
public async Task Summary_NodeReceivedBadValue_IsCountedAsEverWentBad()
|
||||
{
|
||||
@@ -98,6 +101,7 @@ public class SubscribeCommandSummaryTests
|
||||
output.ShouldContain("NEVER went bad (suspect): 0");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscription auto-exits when duration expires.</summary>
|
||||
[Fact]
|
||||
public async Task Duration_ZeroOrPositive_AutoExits()
|
||||
{
|
||||
@@ -124,6 +128,7 @@ public class SubscribeCommandSummaryTests
|
||||
output.ShouldContain("==================== SUMMARY ====================");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that --quiet suppresses updates but prints summary.</summary>
|
||||
[Fact]
|
||||
public async Task Quiet_SuppressesPerUpdateOutputButPrintsSummary()
|
||||
{
|
||||
@@ -154,6 +159,7 @@ public class SubscribeCommandSummaryTests
|
||||
output.ShouldContain("==================== SUMMARY ====================");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that summary is written to disk when summary file is specified.</summary>
|
||||
[Fact]
|
||||
public async Task SummaryFile_WritesSummaryToDisk()
|
||||
{
|
||||
@@ -184,6 +190,7 @@ public class SubscribeCommandSummaryTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that recursive flag browses subtree and subscribes every variable.</summary>
|
||||
[Fact]
|
||||
public async Task Recursive_BrowsesSubtreeAndSubscribesEveryVariable()
|
||||
{
|
||||
@@ -222,6 +229,7 @@ public class SubscribeCommandSummaryTests
|
||||
output.ShouldContain("Browsing subtree of ns=2;s=Root");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscription failures are handled gracefully.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeFailure_PrintsFailedMessage_DoesNotCrash()
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class SubscribeCommandTests
|
||||
{
|
||||
/// <summary>Verifies that ExecuteAsync subscribes with the correct parameters.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_SubscribesWithCorrectParameters()
|
||||
{
|
||||
@@ -35,6 +36,7 @@ public class SubscribeCommandTests
|
||||
fakeService.SubscribeCalls[0].NodeId.Identifier.ShouldBe("TestVar");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ExecuteAsync unsubscribes when cancellation is requested.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_UnsubscribesOnCancellation()
|
||||
{
|
||||
@@ -57,6 +59,7 @@ public class SubscribeCommandTests
|
||||
fakeService.UnsubscribeCalls.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ExecuteAsync disconnects and disposes in a finally block.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsInFinally()
|
||||
{
|
||||
@@ -80,6 +83,7 @@ public class SubscribeCommandTests
|
||||
fakeService.DisposeCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ExecuteAsync prints the correct subscription message.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_PrintsSubscriptionMessage()
|
||||
{
|
||||
|
||||
@@ -18,6 +18,8 @@ public static class TestConsoleHelper
|
||||
/// <summary>
|
||||
/// Reads all text written to the console's standard output.
|
||||
/// </summary>
|
||||
/// <param name="console">The fake console instance.</param>
|
||||
/// <returns>The console output text.</returns>
|
||||
public static string GetOutput(FakeInMemoryConsole console)
|
||||
{
|
||||
console.Output.Flush();
|
||||
@@ -27,6 +29,8 @@ public static class TestConsoleHelper
|
||||
/// <summary>
|
||||
/// Reads all text written to the console's standard error.
|
||||
/// </summary>
|
||||
/// <param name="console">The fake console instance.</param>
|
||||
/// <returns>The console error text.</returns>
|
||||
public static string GetError(FakeInMemoryConsole console)
|
||||
{
|
||||
console.Error.Flush();
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
public class WriteCommandTests
|
||||
{
|
||||
/// <summary>Verifies that write command executes successfully.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_WritesSuccessfully()
|
||||
{
|
||||
@@ -31,6 +32,7 @@ public class WriteCommandTests
|
||||
output.ShouldContain("Write successful: ns=2;s=MyVar = 100");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write command reports failure.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_ReportsFailure()
|
||||
{
|
||||
@@ -54,6 +56,7 @@ public class WriteCommandTests
|
||||
output.ShouldContain("Write failed:");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write command reads current value before writing.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_ReadsCurrentValueThenWrites()
|
||||
{
|
||||
@@ -79,6 +82,7 @@ public class WriteCommandTests
|
||||
fakeService.WriteValues[0].Value.ShouldBeOfType<double>();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write command disconnects in finally block.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_DisconnectsInFinally()
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ClientStoragePathsTests
|
||||
{
|
||||
/// <summary>Verifies that GetRoot returns the canonical folder name under LocalAppData.</summary>
|
||||
[Fact]
|
||||
public void GetRoot_ReturnsCanonicalFolderName_UnderLocalAppData()
|
||||
{
|
||||
@@ -15,6 +16,7 @@ public sealed class ClientStoragePathsTests
|
||||
root.ShouldContain(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetPkiPath nests PKI under root.</summary>
|
||||
[Fact]
|
||||
public void GetPkiPath_NestsPkiUnderRoot()
|
||||
{
|
||||
@@ -22,12 +24,14 @@ public sealed class ClientStoragePathsTests
|
||||
pki.ShouldEndWith(Path.Combine(ClientStoragePaths.CanonicalFolderName, "pki"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that CanonicalFolderName is OtOpcUaClient.</summary>
|
||||
[Fact]
|
||||
public void CanonicalFolderName_IsOtOpcUaClient()
|
||||
{
|
||||
ClientStoragePaths.CanonicalFolderName.ShouldBe("OtOpcUaClient");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LegacyFolderName is LmxOpcUaClient.</summary>
|
||||
[Fact]
|
||||
public void LegacyFolderName_IsLmxOpcUaClient()
|
||||
{
|
||||
@@ -36,6 +40,7 @@ public sealed class ClientStoragePathsTests
|
||||
ClientStoragePaths.LegacyFolderName.ShouldBe("LmxOpcUaClient");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that TryRunLegacyMigration returns false on repeat invocation.</summary>
|
||||
[Fact]
|
||||
public void TryRunLegacyMigration_Returns_False_On_Repeat_Invocation()
|
||||
{
|
||||
|
||||
+6
@@ -6,10 +6,16 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Fakes;
|
||||
|
||||
internal sealed class FakeApplicationConfigurationFactory : IApplicationConfigurationFactory
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether to throw when Create is called.</summary>
|
||||
public bool ThrowOnCreate { get; set; }
|
||||
|
||||
/// <summary>Gets the number of times CreateAsync has been called.</summary>
|
||||
public int CreateCallCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the last connection settings passed to CreateAsync.</summary>
|
||||
public ConnectionSettings? LastSettings { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct)
|
||||
{
|
||||
CreateCallCount++;
|
||||
|
||||
@@ -5,10 +5,18 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Fakes;
|
||||
|
||||
internal sealed class FakeEndpointDiscovery : IEndpointDiscovery
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether SelectEndpoint should throw an exception.</summary>
|
||||
public bool ThrowOnSelect { get; set; }
|
||||
/// <summary>Gets the number of times SelectEndpoint has been called.</summary>
|
||||
public int SelectCallCount { get; private set; }
|
||||
/// <summary>Gets the last endpoint URL passed to SelectEndpoint.</summary>
|
||||
public string? LastEndpointUrl { get; private set; }
|
||||
|
||||
/// <summary>Selects an endpoint for the given configuration and URL, optionally throwing an exception.</summary>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="endpointUrl">The endpoint URL to select.</param>
|
||||
/// <param name="requestedMode">The requested security mode.</param>
|
||||
/// <returns>The selected endpoint description.</returns>
|
||||
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
|
||||
MessageSecurityMode requestedMode)
|
||||
{
|
||||
|
||||
@@ -20,31 +20,53 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
/// Gets a value indicating whether the fake session has been disposed.
|
||||
/// </summary>
|
||||
public bool Disposed { get; private set; }
|
||||
/// <summary>Gets the number of times ReadValueAsync has been called.</summary>
|
||||
public int ReadCount { get; private set; }
|
||||
/// <summary>Gets the number of times WriteValueAsync has been called.</summary>
|
||||
public int WriteCount { get; private set; }
|
||||
/// <summary>Gets the number of times BrowseAsync has been called.</summary>
|
||||
public int BrowseCount { get; private set; }
|
||||
/// <summary>Gets the number of times BrowseNextAsync has been called.</summary>
|
||||
public int BrowseNextCount { get; private set; }
|
||||
/// <summary>Gets the number of times HasChildrenAsync has been called.</summary>
|
||||
public int HasChildrenCount { get; private set; }
|
||||
/// <summary>Gets the number of times HistoryReadRawAsync has been called.</summary>
|
||||
public int HistoryReadRawCount { get; private set; }
|
||||
/// <summary>Gets the number of times HistoryReadAggregateAsync has been called.</summary>
|
||||
public int HistoryReadAggregateCount { get; private set; }
|
||||
|
||||
// Configurable responses
|
||||
/// <summary>Gets or sets the data value returned by read operations.</summary>
|
||||
public DataValue? ReadResponse { get; set; }
|
||||
/// <summary>Gets or sets a function to generate read responses dynamically based on the node ID.</summary>
|
||||
public Func<NodeId, DataValue>? ReadResponseFunc { get; set; }
|
||||
/// <summary>Gets or sets the status code returned by write operations.</summary>
|
||||
public StatusCode WriteResponse { get; set; } = StatusCodes.Good;
|
||||
/// <summary>Gets or sets a value indicating whether read operations should throw an exception.</summary>
|
||||
public bool ThrowOnRead { get; set; }
|
||||
/// <summary>Gets or sets a value indicating whether write operations should throw an exception.</summary>
|
||||
public bool ThrowOnWrite { get; set; }
|
||||
/// <summary>Gets or sets a value indicating whether browse operations should throw an exception.</summary>
|
||||
public bool ThrowOnBrowse { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the browse references returned by browse operations.</summary>
|
||||
public ReferenceDescriptionCollection BrowseResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets the continuation point for browse operations.</summary>
|
||||
public byte[]? BrowseContinuationPoint { get; set; }
|
||||
/// <summary>Gets or sets the browse references returned by browse-next operations.</summary>
|
||||
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets the continuation point for browse-next operations.</summary>
|
||||
public byte[]? BrowseNextContinuationPoint { get; set; }
|
||||
/// <summary>Gets or sets the result returned by has-children operations.</summary>
|
||||
public bool HasChildrenResponse { get; set; } = false;
|
||||
|
||||
/// <summary>Gets or sets the historical values returned by raw history-read operations.</summary>
|
||||
public List<DataValue> HistoryReadRawResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets the historical values returned by aggregate history-read operations.</summary>
|
||||
public List<DataValue> HistoryReadAggregateResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets a value indicating whether raw history-read operations should throw an exception.</summary>
|
||||
public bool ThrowOnHistoryReadRaw { get; set; }
|
||||
/// <summary>Gets or sets a value indicating whether aggregate history-read operations should throw an exception.</summary>
|
||||
public bool ThrowOnHistoryReadAggregate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -210,6 +232,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
/// <summary>
|
||||
/// Simulates a keep-alive event.
|
||||
/// </summary>
|
||||
/// <param name="isGood">Whether the keep-alive status is good.</param>
|
||||
public void SimulateKeepAlive(bool isGood)
|
||||
{
|
||||
_keepAliveCallback?.Invoke(isGood);
|
||||
|
||||
@@ -8,8 +8,11 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
private readonly List<FakeSessionAdapter> _createdSessions = [];
|
||||
private readonly Queue<FakeSessionAdapter> _sessions = new();
|
||||
|
||||
/// <summary>Gets the number of times CreateSessionAsync has been called.</summary>
|
||||
public int CreateCallCount { get; private set; }
|
||||
/// <summary>Gets or sets a value indicating whether CreateSessionAsync should throw.</summary>
|
||||
public bool ThrowOnCreate { get; set; }
|
||||
/// <summary>Gets the endpoint URL from the last CreateSessionAsync call.</summary>
|
||||
public string? LastEndpointUrl { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -18,8 +21,17 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
/// </summary>
|
||||
public TaskCompletionSource? CreateGate { get; set; }
|
||||
|
||||
/// <summary>Gets the list of sessions created via CreateSessionAsync.</summary>
|
||||
public IReadOnlyList<FakeSessionAdapter> CreatedSessions => _createdSessions;
|
||||
|
||||
/// <summary>Creates a session asynchronously (fake implementation).</summary>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="endpoint">The endpoint description.</param>
|
||||
/// <param name="sessionName">The session name.</param>
|
||||
/// <param name="sessionTimeoutMs">The session timeout in milliseconds.</param>
|
||||
/// <param name="identity">The user identity.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task containing the session adapter.</returns>
|
||||
public async Task<ISessionAdapter> CreateSessionAsync(
|
||||
ApplicationConfiguration config, EndpointDescription endpoint, string sessionName,
|
||||
uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct)
|
||||
@@ -54,6 +66,7 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
/// <summary>
|
||||
/// Enqueues a session adapter to be returned on the next call to CreateSessionAsync.
|
||||
/// </summary>
|
||||
/// <param name="session">The session adapter to enqueue.</param>
|
||||
public void EnqueueSession(FakeSessionAdapter session)
|
||||
{
|
||||
_sessions.Enqueue(session);
|
||||
|
||||
@@ -31,8 +31,13 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// Gets or sets a value indicating whether condition refresh should throw to simulate unsupported servers.
|
||||
/// </summary>
|
||||
public bool ThrowOnConditionRefresh { get; set; }
|
||||
/// <summary>Gets the number of times AddDataChangeMonitoredItemAsync was called.</summary>
|
||||
public int AddDataChangeCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times AddEventMonitoredItemAsync was called.</summary>
|
||||
public int AddEventCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times RemoveMonitoredItemAsync was called.</summary>
|
||||
public int RemoveCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -115,6 +120,8 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// <summary>
|
||||
/// Simulates a data change notification for testing.
|
||||
/// </summary>
|
||||
/// <param name="handle">The monitored item handle.</param>
|
||||
/// <param name="value">The new data value to simulate.</param>
|
||||
public void SimulateDataChange(uint handle, DataValue value)
|
||||
{
|
||||
(NodeId NodeId, Action<string, DataValue>? DataCallback, Action<EventFieldList>? EventCallback) item;
|
||||
@@ -129,6 +136,8 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// <summary>
|
||||
/// Simulates an event notification for testing.
|
||||
/// </summary>
|
||||
/// <param name="handle">The monitored item handle.</param>
|
||||
/// <param name="eventFields">The event field list to simulate.</param>
|
||||
public void SimulateEvent(uint handle, EventFieldList eventFields)
|
||||
{
|
||||
(NodeId NodeId, Action<string, DataValue>? DataCallback, Action<EventFieldList>? EventCallback) item;
|
||||
|
||||
+10
@@ -8,6 +8,8 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class AggregateTypeMapperTests
|
||||
{
|
||||
/// <summary>Verifies that ToNodeId returns a non-null NodeId for all AggregateType values.</summary>
|
||||
/// <param name="aggregate">The aggregate type to test.</param>
|
||||
[Theory]
|
||||
[InlineData(AggregateType.Average)]
|
||||
[InlineData(AggregateType.Minimum)]
|
||||
@@ -23,42 +25,49 @@ public class AggregateTypeMapperTests
|
||||
nodeId.IsNullNodeId.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Average aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_Average_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Average).ShouldBe(ObjectIds.AggregateFunction_Average);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Minimum aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_Minimum_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Minimum).ShouldBe(ObjectIds.AggregateFunction_Minimum);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Maximum aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_Maximum_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Maximum).ShouldBe(ObjectIds.AggregateFunction_Maximum);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Count aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_Count_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Count).ShouldBe(ObjectIds.AggregateFunction_Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Start aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_Start_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Start).ShouldBe(ObjectIds.AggregateFunction_Start);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that End aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_End_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.End).ShouldBe(ObjectIds.AggregateFunction_End);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that StandardDeviation aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_StandardDeviation_MapsCorrectly()
|
||||
{
|
||||
@@ -66,6 +75,7 @@ public class AggregateTypeMapperTests
|
||||
.ShouldBe(ObjectIds.AggregateFunction_StandardDeviationPopulation);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid aggregate type value throws ArgumentOutOfRangeException.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_InvalidValue_Throws()
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class FailoverUrlParserTests
|
||||
{
|
||||
/// <summary>Verifies that Parse returns only the primary URL when CSV failover list is null.</summary>
|
||||
[Fact]
|
||||
public void Parse_CsvNull_ReturnsPrimaryOnly()
|
||||
{
|
||||
@@ -13,6 +14,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse returns only the primary URL when CSV failover list is empty.</summary>
|
||||
[Fact]
|
||||
public void Parse_CsvEmpty_ReturnsPrimaryOnly()
|
||||
{
|
||||
@@ -20,6 +22,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse returns only the primary URL when CSV failover list is whitespace.</summary>
|
||||
[Fact]
|
||||
public void Parse_CsvWhitespace_ReturnsPrimaryOnly()
|
||||
{
|
||||
@@ -27,6 +30,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse returns both primary and single failover URL.</summary>
|
||||
[Fact]
|
||||
public void Parse_SingleFailover_ReturnsBoth()
|
||||
{
|
||||
@@ -34,6 +38,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840", "opc.tcp://backup:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse returns primary URL and all failover URLs from CSV list.</summary>
|
||||
[Fact]
|
||||
public void Parse_MultipleFailovers_ReturnsAll()
|
||||
{
|
||||
@@ -41,6 +46,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840", "opc.tcp://backup1:4840", "opc.tcp://backup2:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse trims leading and trailing whitespace from failover URLs.</summary>
|
||||
[Fact]
|
||||
public void Parse_TrimsWhitespace()
|
||||
{
|
||||
@@ -48,6 +54,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840", "opc.tcp://backup:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse deduplicates the primary URL if present in the failover list.</summary>
|
||||
[Fact]
|
||||
public void Parse_DeduplicatesPrimaryInFailoverList()
|
||||
{
|
||||
@@ -55,6 +62,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840", "opc.tcp://backup:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse performs case-insensitive deduplication of URLs.</summary>
|
||||
[Fact]
|
||||
public void Parse_DeduplicatesCaseInsensitive()
|
||||
{
|
||||
@@ -62,6 +70,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://Primary:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse returns only the primary URL when array of failover URLs is null.</summary>
|
||||
[Fact]
|
||||
public void Parse_ArrayNull_ReturnsPrimaryOnly()
|
||||
{
|
||||
@@ -69,6 +78,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse returns only the primary URL when array of failover URLs is empty.</summary>
|
||||
[Fact]
|
||||
public void Parse_ArrayEmpty_ReturnsPrimaryOnly()
|
||||
{
|
||||
@@ -76,6 +86,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse returns primary URL and all failover URLs from array.</summary>
|
||||
[Fact]
|
||||
public void Parse_ArrayWithUrls_ReturnsAll()
|
||||
{
|
||||
@@ -84,6 +95,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840", "opc.tcp://backup1:4840", "opc.tcp://backup2:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse deduplicates URLs from array if primary URL is present.</summary>
|
||||
[Fact]
|
||||
public void Parse_ArrayDeduplicates()
|
||||
{
|
||||
@@ -92,6 +104,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840", "opc.tcp://backup:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse trims whitespace from URLs in array.</summary>
|
||||
[Fact]
|
||||
public void Parse_ArrayTrimsWhitespace()
|
||||
{
|
||||
@@ -100,6 +113,7 @@ public class FailoverUrlParserTests
|
||||
result.ShouldBe(["opc.tcp://primary:4840", "opc.tcp://backup:4840"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse skips null and empty URLs from array.</summary>
|
||||
[Fact]
|
||||
public void Parse_ArraySkipsNullAndEmpty()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class SecurityModeMapperTests
|
||||
{
|
||||
/// <summary>Verifies ToMessageSecurityMode correctly maps SecurityMode values to OPC UA MessageSecurityMode.</summary>
|
||||
/// <param name="input">The SecurityMode value to map.</param>
|
||||
/// <param name="expected">The expected MessageSecurityMode result.</param>
|
||||
[Theory]
|
||||
[InlineData(SecurityMode.None, MessageSecurityMode.None)]
|
||||
[InlineData(SecurityMode.Sign, MessageSecurityMode.Sign)]
|
||||
@@ -17,6 +20,7 @@ public class SecurityModeMapperTests
|
||||
SecurityModeMapper.ToMessageSecurityMode(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies ToMessageSecurityMode throws on invalid SecurityMode values.</summary>
|
||||
[Fact]
|
||||
public void ToMessageSecurityMode_InvalidValue_Throws()
|
||||
{
|
||||
@@ -24,6 +28,9 @@ public class SecurityModeMapperTests
|
||||
SecurityModeMapper.ToMessageSecurityMode((SecurityMode)99));
|
||||
}
|
||||
|
||||
/// <summary>Verifies FromString correctly parses security mode strings (case-insensitive).</summary>
|
||||
/// <param name="input">The security mode string to parse.</param>
|
||||
/// <param name="expected">The expected SecurityMode result.</param>
|
||||
[Theory]
|
||||
[InlineData("none", SecurityMode.None)]
|
||||
[InlineData("None", SecurityMode.None)]
|
||||
@@ -38,18 +45,21 @@ public class SecurityModeMapperTests
|
||||
SecurityModeMapper.FromString(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies FromString correctly parses strings with leading and trailing whitespace.</summary>
|
||||
[Fact]
|
||||
public void FromString_WithWhitespace_ParsesCorrectly()
|
||||
{
|
||||
SecurityModeMapper.FromString(" sign ").ShouldBe(SecurityMode.Sign);
|
||||
}
|
||||
|
||||
/// <summary>Verifies FromString throws on unrecognized security mode strings.</summary>
|
||||
[Fact]
|
||||
public void FromString_UnknownValue_Throws()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => SecurityModeMapper.FromString("invalid"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies FromString returns None when passed a null string.</summary>
|
||||
[Fact]
|
||||
public void FromString_Null_DefaultsToNone()
|
||||
{
|
||||
|
||||
@@ -6,96 +6,112 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class ValueConverterTests
|
||||
{
|
||||
/// <summary>Verifies that a boolean string "True" is converted to true.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_Bool_True()
|
||||
{
|
||||
ValueConverter.ConvertValue("True", true).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a boolean string "False" is converted to false.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_Bool_False()
|
||||
{
|
||||
ValueConverter.ConvertValue("False", false).ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a byte value is converted correctly.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_Byte()
|
||||
{
|
||||
ValueConverter.ConvertValue("255", (byte)0).ShouldBe((byte)255);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a short value is converted correctly.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_Short()
|
||||
{
|
||||
ValueConverter.ConvertValue("-100", (short)0).ShouldBe((short)-100);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unsigned short value is converted correctly.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_UShort()
|
||||
{
|
||||
ValueConverter.ConvertValue("65535", (ushort)0).ShouldBe((ushort)65535);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an integer value is converted correctly.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_Int()
|
||||
{
|
||||
ValueConverter.ConvertValue("42", 0).ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unsigned integer value is converted correctly.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_UInt()
|
||||
{
|
||||
ValueConverter.ConvertValue("42", 0u).ShouldBe(42u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a long value is converted correctly.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_Long()
|
||||
{
|
||||
ValueConverter.ConvertValue("9999999999", 0L).ShouldBe(9999999999L);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unsigned long value is converted correctly.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_ULong()
|
||||
{
|
||||
ValueConverter.ConvertValue("18446744073709551615", 0UL).ShouldBe(ulong.MaxValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a float value is converted correctly.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_Float()
|
||||
{
|
||||
ValueConverter.ConvertValue("3.14", 0f).ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a double value is converted correctly.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_Double()
|
||||
{
|
||||
ValueConverter.ConvertValue("3.14159", 0.0).ShouldBe(3.14159);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a string value is converted correctly when the current value is a string.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_String_WhenCurrentIsString()
|
||||
{
|
||||
ValueConverter.ConvertValue("hello", "").ShouldBe("hello");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a string value is converted correctly when the current value is null.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_String_WhenCurrentIsNull()
|
||||
{
|
||||
ValueConverter.ConvertValue("hello", null).ShouldBe("hello");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a string value is converted correctly when the current value is an unknown type.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_String_WhenCurrentIsUnknownType()
|
||||
{
|
||||
ValueConverter.ConvertValue("hello", new object()).ShouldBe("hello");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting an invalid boolean value throws a FormatException.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_InvalidBool_Throws()
|
||||
{
|
||||
Should.Throw<FormatException>(() => ValueConverter.ConvertValue("notabool", true));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting an invalid integer value throws a FormatException with a descriptive message.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_InvalidInt_ThrowsWithDescription()
|
||||
{
|
||||
@@ -104,6 +120,7 @@ public class ValueConverterTests
|
||||
ex.Message.ShouldContain("notanint");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an overflow during conversion throws a FormatException.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_Overflow_ThrowsFormatException()
|
||||
{
|
||||
@@ -114,6 +131,9 @@ public class ValueConverterTests
|
||||
|
||||
// --- Client.Shared-008: Boolean aliases ---
|
||||
|
||||
/// <summary>Verifies that boolean values accept numeric and word aliases.</summary>
|
||||
/// <param name="input">The input string value.</param>
|
||||
/// <param name="expected">The expected boolean value.</param>
|
||||
[Theory]
|
||||
[InlineData("1", true)]
|
||||
[InlineData("0", false)]
|
||||
@@ -130,6 +150,7 @@ public class ValueConverterTests
|
||||
ValueConverter.ConvertValue(input, true).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that converting an invalid boolean value throws a descriptive FormatException.</summary>
|
||||
[Fact]
|
||||
public void ConvertValue_InvalidBool_ThrowsDescriptiveFormatException()
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Models;
|
||||
|
||||
public class ConnectionSettingsTests
|
||||
{
|
||||
/// <summary>Verifies that ConnectionSettings defaults are correct.</summary>
|
||||
[Fact]
|
||||
public void Defaults_AreCorrect()
|
||||
{
|
||||
@@ -24,6 +25,7 @@ public class ConnectionSettingsTests
|
||||
settings.CertificateStorePath.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation throws on null endpoint URL.</summary>
|
||||
[Fact]
|
||||
public void Validate_ThrowsOnNullEndpointUrl()
|
||||
{
|
||||
@@ -32,6 +34,7 @@ public class ConnectionSettingsTests
|
||||
.ParamName.ShouldBe("EndpointUrl");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation throws on empty endpoint URL.</summary>
|
||||
[Fact]
|
||||
public void Validate_ThrowsOnEmptyEndpointUrl()
|
||||
{
|
||||
@@ -40,6 +43,7 @@ public class ConnectionSettingsTests
|
||||
.ParamName.ShouldBe("EndpointUrl");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation throws on whitespace endpoint URL.</summary>
|
||||
[Fact]
|
||||
public void Validate_ThrowsOnWhitespaceEndpointUrl()
|
||||
{
|
||||
@@ -48,6 +52,7 @@ public class ConnectionSettingsTests
|
||||
.ParamName.ShouldBe("EndpointUrl");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation throws on zero timeout.</summary>
|
||||
[Fact]
|
||||
public void Validate_ThrowsOnZeroTimeout()
|
||||
{
|
||||
@@ -60,6 +65,7 @@ public class ConnectionSettingsTests
|
||||
.ParamName.ShouldBe("SessionTimeoutSeconds");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation throws on negative timeout.</summary>
|
||||
[Fact]
|
||||
public void Validate_ThrowsOnNegativeTimeout()
|
||||
{
|
||||
@@ -72,6 +78,7 @@ public class ConnectionSettingsTests
|
||||
.ParamName.ShouldBe("SessionTimeoutSeconds");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation throws on timeout above 3600 seconds.</summary>
|
||||
[Fact]
|
||||
public void Validate_ThrowsOnTimeoutAbove3600()
|
||||
{
|
||||
@@ -84,6 +91,7 @@ public class ConnectionSettingsTests
|
||||
.ParamName.ShouldBe("SessionTimeoutSeconds");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that validation succeeds with valid settings.</summary>
|
||||
[Fact]
|
||||
public void Validate_SucceedsWithValidSettings()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Models;
|
||||
|
||||
public class ModelConstructionTests
|
||||
{
|
||||
/// <summary>Verifies that BrowseResult constructs correctly with all properties.</summary>
|
||||
[Fact]
|
||||
public void BrowseResult_ConstructsCorrectly()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public class ModelConstructionTests
|
||||
result.HasChildren.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BrowseResult correctly sets HasChildren to false.</summary>
|
||||
[Fact]
|
||||
public void BrowseResult_WithoutChildren()
|
||||
{
|
||||
@@ -26,6 +28,7 @@ public class ModelConstructionTests
|
||||
result.HasChildren.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AlarmEventArgs constructs correctly with all properties.</summary>
|
||||
[Fact]
|
||||
public void AlarmEventArgs_ConstructsCorrectly()
|
||||
{
|
||||
@@ -42,6 +45,7 @@ public class ModelConstructionTests
|
||||
args.Time.ShouldBe(time);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RedundancyInfo constructs correctly with all properties.</summary>
|
||||
[Fact]
|
||||
public void RedundancyInfo_ConstructsCorrectly()
|
||||
{
|
||||
@@ -54,6 +58,7 @@ public class ModelConstructionTests
|
||||
info.ApplicationUri.ShouldBe("urn:server1");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RedundancyInfo handles empty URIs correctly.</summary>
|
||||
[Fact]
|
||||
public void RedundancyInfo_WithEmptyUris()
|
||||
{
|
||||
@@ -62,6 +67,7 @@ public class ModelConstructionTests
|
||||
info.ApplicationUri.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DataChangedEventArgs constructs correctly with all properties.</summary>
|
||||
[Fact]
|
||||
public void DataChangedEventArgs_ConstructsCorrectly()
|
||||
{
|
||||
@@ -73,6 +79,7 @@ public class ModelConstructionTests
|
||||
args.Value.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ConnectionStateChangedEventArgs constructs correctly with all properties.</summary>
|
||||
[Fact]
|
||||
public void ConnectionStateChangedEventArgs_ConstructsCorrectly()
|
||||
{
|
||||
@@ -84,6 +91,7 @@ public class ModelConstructionTests
|
||||
args.EndpointUrl.ShouldBe("opc.tcp://localhost:4840");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ConnectionInfo constructs correctly with all properties.</summary>
|
||||
[Fact]
|
||||
public void ConnectionInfo_ConstructsCorrectly()
|
||||
{
|
||||
@@ -103,6 +111,7 @@ public class ModelConstructionTests
|
||||
info.SessionName.ShouldBe("TestSession");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SecurityMode enum has the expected number of values.</summary>
|
||||
[Fact]
|
||||
public void SecurityMode_Enum_HasExpectedValues()
|
||||
{
|
||||
@@ -112,6 +121,7 @@ public class ModelConstructionTests
|
||||
((int)SecurityMode.SignAndEncrypt).ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ConnectionState enum has the expected number of values.</summary>
|
||||
[Fact]
|
||||
public void ConnectionState_Enum_HasExpectedValues()
|
||||
{
|
||||
@@ -122,6 +132,7 @@ public class ModelConstructionTests
|
||||
((int)ConnectionState.Reconnecting).ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AggregateType enum has the expected number of values.</summary>
|
||||
[Fact]
|
||||
public void AggregateType_Enum_HasExpectedValues()
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
private readonly OpcUaClientService _service;
|
||||
private readonly FakeSessionFactory _sessionFactory = new();
|
||||
|
||||
/// <summary>Initializes a new test instance with fake dependencies.</summary>
|
||||
public OpcUaClientServiceTests()
|
||||
{
|
||||
_service = new OpcUaClientService(_configFactory, _endpointDiscovery, _sessionFactory);
|
||||
|
||||
@@ -12,6 +12,7 @@ public class AlarmsViewModelTests
|
||||
private readonly FakeOpcUaClientService _service;
|
||||
private readonly AlarmsViewModel _vm;
|
||||
|
||||
/// <summary>Initializes a new test instance.</summary>
|
||||
public AlarmsViewModelTests()
|
||||
{
|
||||
_service = new FakeOpcUaClientService();
|
||||
@@ -19,6 +20,7 @@ public class AlarmsViewModelTests
|
||||
_vm = new AlarmsViewModel(_service, dispatcher);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeCommand cannot execute when disconnected.</summary>
|
||||
[Fact]
|
||||
public void SubscribeCommand_CannotExecute_WhenDisconnected()
|
||||
{
|
||||
@@ -26,6 +28,7 @@ public class AlarmsViewModelTests
|
||||
_vm.SubscribeCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeCommand cannot execute when already subscribed.</summary>
|
||||
[Fact]
|
||||
public void SubscribeCommand_CannotExecute_WhenAlreadySubscribed()
|
||||
{
|
||||
@@ -34,6 +37,7 @@ public class AlarmsViewModelTests
|
||||
_vm.SubscribeCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeCommand can execute when connected and not subscribed.</summary>
|
||||
[Fact]
|
||||
public void SubscribeCommand_CanExecute_WhenConnectedAndNotSubscribed()
|
||||
{
|
||||
@@ -42,6 +46,7 @@ public class AlarmsViewModelTests
|
||||
_vm.SubscribeCommand.CanExecute(null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SubscribeCommand sets IsSubscribed flag.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeCommand_SetsIsSubscribed()
|
||||
{
|
||||
@@ -53,6 +58,7 @@ public class AlarmsViewModelTests
|
||||
_service.SubscribeAlarmsCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnsubscribeCommand cannot execute when not subscribed.</summary>
|
||||
[Fact]
|
||||
public void UnsubscribeCommand_CannotExecute_WhenNotSubscribed()
|
||||
{
|
||||
@@ -61,6 +67,7 @@ public class AlarmsViewModelTests
|
||||
_vm.UnsubscribeCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnsubscribeCommand clears IsSubscribed flag.</summary>
|
||||
[Fact]
|
||||
public async Task UnsubscribeCommand_ClearsIsSubscribed()
|
||||
{
|
||||
@@ -73,6 +80,7 @@ public class AlarmsViewModelTests
|
||||
_service.UnsubscribeAlarmsCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RefreshCommand calls the service.</summary>
|
||||
[Fact]
|
||||
public async Task RefreshCommand_CallsService()
|
||||
{
|
||||
@@ -84,6 +92,7 @@ public class AlarmsViewModelTests
|
||||
_service.RequestConditionRefreshCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RefreshCommand cannot execute when not subscribed.</summary>
|
||||
[Fact]
|
||||
public void RefreshCommand_CannotExecute_WhenNotSubscribed()
|
||||
{
|
||||
@@ -92,6 +101,7 @@ public class AlarmsViewModelTests
|
||||
_vm.RefreshCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm events are added to the collection.</summary>
|
||||
[Fact]
|
||||
public void AlarmEvent_AddsToCollection()
|
||||
{
|
||||
@@ -108,6 +118,7 @@ public class AlarmsViewModelTests
|
||||
_vm.AlarmEvents[0].Message.ShouldBe("Temperature high");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Clear resets the view model state.</summary>
|
||||
[Fact]
|
||||
public void Clear_ResetsState()
|
||||
{
|
||||
@@ -120,6 +131,7 @@ public class AlarmsViewModelTests
|
||||
_vm.IsSubscribed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Teardown unregisters the event handler.</summary>
|
||||
[Fact]
|
||||
public void Teardown_UnhooksEventHandler()
|
||||
{
|
||||
@@ -133,6 +145,7 @@ public class AlarmsViewModelTests
|
||||
_vm.AlarmEvents.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the default polling interval is 1000ms.</summary>
|
||||
[Fact]
|
||||
public void DefaultInterval_Is1000()
|
||||
{
|
||||
|
||||
@@ -7,12 +7,14 @@ using BrowseResult = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.BrowseResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.UI.Tests;
|
||||
|
||||
/// <summary>Tests for the BrowseTreeViewModel class.</summary>
|
||||
public class BrowseTreeViewModelTests
|
||||
{
|
||||
private readonly SynchronousUiDispatcher _dispatcher;
|
||||
private readonly FakeOpcUaClientService _service;
|
||||
private readonly BrowseTreeViewModel _vm;
|
||||
|
||||
/// <summary>Initializes a new instance of the BrowseTreeViewModelTests class.</summary>
|
||||
public BrowseTreeViewModelTests()
|
||||
{
|
||||
_service = new FakeOpcUaClientService
|
||||
@@ -27,6 +29,7 @@ public class BrowseTreeViewModelTests
|
||||
_vm = new BrowseTreeViewModel(_service, _dispatcher);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LoadRootsAsync populates root nodes.</summary>
|
||||
[Fact]
|
||||
public async Task LoadRootsAsync_PopulatesRootNodes()
|
||||
{
|
||||
@@ -37,6 +40,7 @@ public class BrowseTreeViewModelTests
|
||||
_vm.RootNodes[1].DisplayName.ShouldBe("Node2");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LoadRootsAsync browses with null parent.</summary>
|
||||
[Fact]
|
||||
public async Task LoadRootsAsync_BrowsesWithNullParent()
|
||||
{
|
||||
@@ -46,6 +50,7 @@ public class BrowseTreeViewModelTests
|
||||
_service.LastBrowseParentNodeId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Clear removes all root nodes.</summary>
|
||||
[Fact]
|
||||
public void Clear_RemovesAllRootNodes()
|
||||
{
|
||||
@@ -55,6 +60,7 @@ public class BrowseTreeViewModelTests
|
||||
_vm.RootNodes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nodes with children have a placeholder.</summary>
|
||||
[Fact]
|
||||
public async Task LoadRootsAsync_NodeWithChildren_HasPlaceholder()
|
||||
{
|
||||
@@ -66,6 +72,7 @@ public class BrowseTreeViewModelTests
|
||||
nodeWithChildren.Children[0].IsPlaceholder.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nodes without children have no placeholder.</summary>
|
||||
[Fact]
|
||||
public async Task LoadRootsAsync_NodeWithoutChildren_HasNoPlaceholder()
|
||||
{
|
||||
@@ -76,6 +83,7 @@ public class BrowseTreeViewModelTests
|
||||
leafNode.Children.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that first tree node expand triggers child browse.</summary>
|
||||
[Fact]
|
||||
public async Task TreeNode_FirstExpand_TriggersChildBrowse()
|
||||
{
|
||||
@@ -105,6 +113,7 @@ public class BrowseTreeViewModelTests
|
||||
parent.Children[0].DisplayName.ShouldBe("Child1");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that second tree node expand does not browse again.</summary>
|
||||
[Fact]
|
||||
public async Task TreeNode_SecondExpand_DoesNotBrowseAgain()
|
||||
{
|
||||
@@ -133,6 +142,7 @@ public class BrowseTreeViewModelTests
|
||||
_service.BrowseCallCount.ShouldBe(browseCountAfterFirst);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that IsLoading transitions during browse.</summary>
|
||||
[Fact]
|
||||
public async Task TreeNode_IsLoading_TransitionsDuringBrowse()
|
||||
{
|
||||
|
||||
@@ -84,28 +84,50 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
public Exception? HistoryException { get; set; }
|
||||
|
||||
// Call tracking
|
||||
/// <summary>Gets the number of times ConnectAsync has been called.</summary>
|
||||
public int ConnectCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times DisconnectAsync has been called.</summary>
|
||||
public int DisconnectCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times ReadValueAsync has been called.</summary>
|
||||
public int ReadCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times WriteValueAsync has been called.</summary>
|
||||
public int WriteCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times BrowseAsync has been called.</summary>
|
||||
public int BrowseCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times SubscribeAsync has been called.</summary>
|
||||
public int SubscribeCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times UnsubscribeAsync has been called.</summary>
|
||||
public int UnsubscribeCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times SubscribeAlarmsAsync has been called.</summary>
|
||||
public int SubscribeAlarmsCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times UnsubscribeAlarmsAsync has been called.</summary>
|
||||
public int UnsubscribeAlarmsCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times RequestConditionRefreshAsync has been called.</summary>
|
||||
public int RequestConditionRefreshCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times HistoryReadRawAsync has been called.</summary>
|
||||
public int HistoryReadRawCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times HistoryReadAggregateAsync has been called.</summary>
|
||||
public int HistoryReadAggregateCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times GetRedundancyInfoAsync has been called.</summary>
|
||||
public int GetRedundancyInfoCallCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the connection settings from the last ConnectAsync call.</summary>
|
||||
public ConnectionSettings? LastConnectionSettings { get; private set; }
|
||||
/// <summary>Gets the node ID from the last ReadValueAsync call.</summary>
|
||||
public NodeId? LastReadNodeId { get; private set; }
|
||||
/// <summary>Gets the node ID from the last WriteValueAsync call.</summary>
|
||||
public NodeId? LastWriteNodeId { get; private set; }
|
||||
/// <summary>Gets the value from the last WriteValueAsync call.</summary>
|
||||
public object? LastWriteValue { get; private set; }
|
||||
/// <summary>Gets the parent node ID from the last BrowseAsync call.</summary>
|
||||
public NodeId? LastBrowseParentNodeId { get; private set; }
|
||||
/// <summary>Gets the node ID from the last SubscribeAsync call.</summary>
|
||||
public NodeId? LastSubscribeNodeId { get; private set; }
|
||||
/// <summary>Gets the interval in milliseconds from the last SubscribeAsync call.</summary>
|
||||
public int LastSubscribeIntervalMs { get; private set; }
|
||||
/// <summary>Gets the node ID from the last UnsubscribeAsync call.</summary>
|
||||
public NodeId? LastUnsubscribeNodeId { get; private set; }
|
||||
/// <summary>Gets the aggregate type from the last HistoryReadAggregateAsync call.</summary>
|
||||
public AggregateType? LastAggregateType { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -225,8 +247,11 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the status code returned by acknowledgment operations in UI tests.</summary>
|
||||
public StatusCode AcknowledgeResult { get; set; } = StatusCodes.Good;
|
||||
/// <summary>Gets or sets the exception thrown to simulate alarm acknowledgment failures in the UI.</summary>
|
||||
public Exception? AcknowledgeException { get; set; }
|
||||
/// <summary>Gets the number of times AcknowledgeAlarmAsync has been called.</summary>
|
||||
public int AcknowledgeCallCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -276,6 +301,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
/// <summary>
|
||||
/// Raises a simulated data-change notification so UI tests can validate live update handling.
|
||||
/// </summary>
|
||||
/// <param name="args">The data change event arguments to raise.</param>
|
||||
public void RaiseDataChanged(DataChangedEventArgs args)
|
||||
{
|
||||
DataChanged?.Invoke(this, args);
|
||||
@@ -284,6 +310,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
/// <summary>
|
||||
/// Raises a simulated alarm event so UI tests can validate alarm-list behavior.
|
||||
/// </summary>
|
||||
/// <param name="args">The alarm event arguments to raise.</param>
|
||||
public void RaiseAlarmEvent(AlarmEventArgs args)
|
||||
{
|
||||
AlarmEvent?.Invoke(this, args);
|
||||
@@ -292,6 +319,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
/// <summary>
|
||||
/// Raises a simulated connection-state transition so UI tests can validate status presentation and failover behavior.
|
||||
/// </summary>
|
||||
/// <param name="args">The connection state changed event arguments to raise.</param>
|
||||
public void RaiseConnectionStateChanged(ConnectionStateChangedEventArgs args)
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(this, args);
|
||||
|
||||
@@ -9,11 +9,15 @@ public sealed class FakeOpcUaClientServiceFactory : IOpcUaClientServiceFactory
|
||||
{
|
||||
private readonly FakeOpcUaClientService _service;
|
||||
|
||||
/// <summary>Initializes a new instance of FakeOpcUaClientServiceFactory.</summary>
|
||||
/// <param name="service">The fake OPC UA client service to return.</param>
|
||||
public FakeOpcUaClientServiceFactory(FakeOpcUaClientService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// <summary>Creates an OPC UA client service instance.</summary>
|
||||
/// <returns>The preconfigured fake OPC UA client service.</returns>
|
||||
public IOpcUaClientService Create()
|
||||
{
|
||||
return _service;
|
||||
|
||||
@@ -4,17 +4,24 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Tests.Fakes;
|
||||
|
||||
public sealed class FakeSettingsService : ISettingsService
|
||||
{
|
||||
/// <summary>Gets or sets the settings held by this fake service.</summary>
|
||||
public UserSettings Settings { get; set; } = new();
|
||||
/// <summary>Gets the number of times Load has been called.</summary>
|
||||
public int LoadCallCount { get; private set; }
|
||||
/// <summary>Gets the number of times Save has been called.</summary>
|
||||
public int SaveCallCount { get; private set; }
|
||||
/// <summary>Gets the last settings that were saved.</summary>
|
||||
public UserSettings? LastSaved { get; private set; }
|
||||
|
||||
/// <summary>Loads and returns the current settings.</summary>
|
||||
public UserSettings Load()
|
||||
{
|
||||
LoadCallCount++;
|
||||
return Settings;
|
||||
}
|
||||
|
||||
/// <summary>Saves the specified settings.</summary>
|
||||
/// <param name="settings">The settings to save.</param>
|
||||
public void Save(UserSettings settings)
|
||||
{
|
||||
SaveCallCount++;
|
||||
|
||||
@@ -13,6 +13,7 @@ public class HistoryViewModelTests
|
||||
private readonly FakeOpcUaClientService _service;
|
||||
private readonly HistoryViewModel _vm;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="HistoryViewModelTests"/> class.</summary>
|
||||
public HistoryViewModelTests()
|
||||
{
|
||||
_service = new FakeOpcUaClientService
|
||||
@@ -31,6 +32,7 @@ public class HistoryViewModelTests
|
||||
_vm = new HistoryViewModel(_service, dispatcher);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read history command cannot execute when disconnected.</summary>
|
||||
[Fact]
|
||||
public void ReadHistoryCommand_CannotExecute_WhenDisconnected()
|
||||
{
|
||||
@@ -39,6 +41,7 @@ public class HistoryViewModelTests
|
||||
_vm.ReadHistoryCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read history command cannot execute when no node is selected.</summary>
|
||||
[Fact]
|
||||
public void ReadHistoryCommand_CannotExecute_WhenNoNodeSelected()
|
||||
{
|
||||
@@ -47,6 +50,7 @@ public class HistoryViewModelTests
|
||||
_vm.ReadHistoryCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read history command can execute when connected and a node is selected.</summary>
|
||||
[Fact]
|
||||
public void ReadHistoryCommand_CanExecute_WhenConnectedAndNodeSelected()
|
||||
{
|
||||
@@ -55,6 +59,7 @@ public class HistoryViewModelTests
|
||||
_vm.ReadHistoryCommand.CanExecute(null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a raw history read populates results correctly.</summary>
|
||||
[Fact]
|
||||
public async Task ReadHistoryCommand_Raw_PopulatesResults()
|
||||
{
|
||||
@@ -71,6 +76,7 @@ public class HistoryViewModelTests
|
||||
_service.HistoryReadAggregateCallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an aggregate history read populates results correctly.</summary>
|
||||
[Fact]
|
||||
public async Task ReadHistoryCommand_Aggregate_PopulatesResults()
|
||||
{
|
||||
@@ -87,6 +93,7 @@ public class HistoryViewModelTests
|
||||
_service.HistoryReadRawCallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read history command clears previous results before loading new ones.</summary>
|
||||
[Fact]
|
||||
public async Task ReadHistoryCommand_ClearsResultsBefore()
|
||||
{
|
||||
@@ -99,6 +106,7 @@ public class HistoryViewModelTests
|
||||
_vm.Results.ShouldNotContain(r => r.Value == "old");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the loading state is false after the read history command completes.</summary>
|
||||
[Fact]
|
||||
public async Task ReadHistoryCommand_IsLoading_FalseAfterComplete()
|
||||
{
|
||||
@@ -110,6 +118,7 @@ public class HistoryViewModelTests
|
||||
_vm.IsLoading.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that default values are initialized correctly.</summary>
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
@@ -119,6 +128,7 @@ public class HistoryViewModelTests
|
||||
_vm.IsAggregateRead.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that IsAggregateRead returns true when an aggregate type is selected.</summary>
|
||||
[Fact]
|
||||
public void IsAggregateRead_TrueWhenAggregateSelected()
|
||||
{
|
||||
@@ -126,6 +136,7 @@ public class HistoryViewModelTests
|
||||
_vm.IsAggregateRead.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the aggregate types collection contains null for raw reads.</summary>
|
||||
[Fact]
|
||||
public void AggregateTypes_ContainsNullForRaw()
|
||||
{
|
||||
@@ -133,6 +144,7 @@ public class HistoryViewModelTests
|
||||
_vm.AggregateTypes.Count.ShouldBe(8); // null + 7 enum values
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Clear method resets the view model state.</summary>
|
||||
[Fact]
|
||||
public void Clear_ResetsState()
|
||||
{
|
||||
@@ -145,6 +157,7 @@ public class HistoryViewModelTests
|
||||
_vm.SelectedNodeId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that read history command errors are displayed in the results.</summary>
|
||||
[Fact]
|
||||
public async Task ReadHistoryCommand_Error_ShowsErrorInResults()
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ public class MainWindowViewModelTests
|
||||
private readonly FakeSettingsService _settingsService;
|
||||
private readonly MainWindowViewModel _vm;
|
||||
|
||||
/// <summary>Initializes test fixtures with default client and settings services.</summary>
|
||||
public MainWindowViewModelTests()
|
||||
{
|
||||
_service = new FakeOpcUaClientService
|
||||
|
||||
@@ -12,6 +12,7 @@ public class ReadWriteViewModelTests
|
||||
private readonly FakeOpcUaClientService _service;
|
||||
private readonly ReadWriteViewModel _vm;
|
||||
|
||||
/// <summary>Initializes a new instance of the ReadWriteViewModelTests class.</summary>
|
||||
public ReadWriteViewModelTests()
|
||||
{
|
||||
_service = new FakeOpcUaClientService
|
||||
@@ -22,6 +23,7 @@ public class ReadWriteViewModelTests
|
||||
_vm = new ReadWriteViewModel(_service, dispatcher);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read command cannot execute when disconnected.</summary>
|
||||
[Fact]
|
||||
public void ReadCommand_CannotExecute_WhenDisconnected()
|
||||
{
|
||||
@@ -30,6 +32,7 @@ public class ReadWriteViewModelTests
|
||||
_vm.ReadCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read command cannot execute when no node is selected.</summary>
|
||||
[Fact]
|
||||
public void ReadCommand_CannotExecute_WhenNoNodeSelected()
|
||||
{
|
||||
@@ -38,6 +41,7 @@ public class ReadWriteViewModelTests
|
||||
_vm.ReadCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read command can execute when connected and a node is selected.</summary>
|
||||
[Fact]
|
||||
public void ReadCommand_CanExecute_WhenConnectedAndNodeSelected()
|
||||
{
|
||||
@@ -46,6 +50,7 @@ public class ReadWriteViewModelTests
|
||||
_vm.ReadCommand.CanExecute(null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read command updates value and status.</summary>
|
||||
[Fact]
|
||||
public async Task ReadCommand_UpdatesValueAndStatus()
|
||||
{
|
||||
@@ -63,6 +68,7 @@ public class ReadWriteViewModelTests
|
||||
(_service.ReadCallCount - countBefore).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that auto-read fires on selection change when connected.</summary>
|
||||
[Fact]
|
||||
public void AutoRead_OnSelectionChange_WhenConnected()
|
||||
{
|
||||
@@ -74,6 +80,7 @@ public class ReadWriteViewModelTests
|
||||
_service.ReadCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null selection does not call the service.</summary>
|
||||
[Fact]
|
||||
public void NullSelection_DoesNotCallService()
|
||||
{
|
||||
@@ -83,6 +90,7 @@ public class ReadWriteViewModelTests
|
||||
_service.ReadCallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the write command updates write status.</summary>
|
||||
[Fact]
|
||||
public async Task WriteCommand_UpdatesWriteStatus()
|
||||
{
|
||||
@@ -100,6 +108,7 @@ public class ReadWriteViewModelTests
|
||||
_service.LastWriteValue.ShouldBe("NewValue");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the write command cannot execute when disconnected.</summary>
|
||||
[Fact]
|
||||
public void WriteCommand_CannotExecute_WhenDisconnected()
|
||||
{
|
||||
@@ -108,6 +117,7 @@ public class ReadWriteViewModelTests
|
||||
_vm.WriteCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that read command error sets error status.</summary>
|
||||
[Fact]
|
||||
public async Task ReadCommand_Error_SetsErrorStatus()
|
||||
{
|
||||
@@ -121,6 +131,7 @@ public class ReadWriteViewModelTests
|
||||
_vm.CurrentStatus.ShouldContain("Error");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that clear resets all properties.</summary>
|
||||
[Fact]
|
||||
public void Clear_ResetsAllProperties()
|
||||
{
|
||||
@@ -140,6 +151,7 @@ public class ReadWriteViewModelTests
|
||||
_vm.WriteStatus.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that IsNodeSelected tracks the selected node ID.</summary>
|
||||
[Fact]
|
||||
public void IsNodeSelected_TracksSelectedNodeId()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ public class SubscriptionsViewModelTests
|
||||
private readonly FakeOpcUaClientService _service;
|
||||
private readonly SubscriptionsViewModel _vm;
|
||||
|
||||
/// <summary>Initializes test instance with fake services.</summary>
|
||||
public SubscriptionsViewModelTests()
|
||||
{
|
||||
_service = new FakeOpcUaClientService();
|
||||
@@ -21,6 +22,7 @@ public class SubscriptionsViewModelTests
|
||||
_vm = new SubscriptionsViewModel(_service, dispatcher);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddSubscriptionCommand cannot execute when disconnected.</summary>
|
||||
[Fact]
|
||||
public void AddSubscriptionCommand_CannotExecute_WhenDisconnected()
|
||||
{
|
||||
@@ -29,6 +31,7 @@ public class SubscriptionsViewModelTests
|
||||
_vm.AddSubscriptionCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddSubscriptionCommand cannot execute without a node ID.</summary>
|
||||
[Fact]
|
||||
public void AddSubscriptionCommand_CannotExecute_WhenNoNodeId()
|
||||
{
|
||||
@@ -37,6 +40,7 @@ public class SubscriptionsViewModelTests
|
||||
_vm.AddSubscriptionCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddSubscriptionCommand adds a new subscription to the active list.</summary>
|
||||
[Fact]
|
||||
public async Task AddSubscriptionCommand_AddsItem()
|
||||
{
|
||||
@@ -53,6 +57,7 @@ public class SubscriptionsViewModelTests
|
||||
_service.SubscribeCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RemoveSubscriptionCommand removes selected subscription.</summary>
|
||||
[Fact]
|
||||
public async Task RemoveSubscriptionCommand_RemovesItem()
|
||||
{
|
||||
@@ -68,6 +73,7 @@ public class SubscriptionsViewModelTests
|
||||
_service.UnsubscribeCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RemoveSubscriptionCommand cannot execute without selection.</summary>
|
||||
[Fact]
|
||||
public void RemoveSubscriptionCommand_CannotExecute_WhenNoSelection()
|
||||
{
|
||||
@@ -76,6 +82,7 @@ public class SubscriptionsViewModelTests
|
||||
_vm.RemoveSubscriptionCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DataChanged event updates the matching subscription row.</summary>
|
||||
[Fact]
|
||||
public async Task DataChanged_UpdatesMatchingRow()
|
||||
{
|
||||
@@ -90,6 +97,7 @@ public class SubscriptionsViewModelTests
|
||||
_vm.ActiveSubscriptions[0].Status.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DataChanged event does not update non-matching subscription rows.</summary>
|
||||
[Fact]
|
||||
public async Task DataChanged_DoesNotUpdateNonMatchingRow()
|
||||
{
|
||||
@@ -103,6 +111,7 @@ public class SubscriptionsViewModelTests
|
||||
_vm.ActiveSubscriptions[0].Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Clear removes all subscriptions.</summary>
|
||||
[Fact]
|
||||
public void Clear_RemovesAllSubscriptions()
|
||||
{
|
||||
@@ -115,6 +124,7 @@ public class SubscriptionsViewModelTests
|
||||
_vm.SubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Teardown unregisters the event handler.</summary>
|
||||
[Fact]
|
||||
public void Teardown_UnhooksEventHandler()
|
||||
{
|
||||
@@ -128,12 +138,14 @@ public class SubscriptionsViewModelTests
|
||||
_vm.ActiveSubscriptions[0].Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that default interval is 1000 milliseconds.</summary>
|
||||
[Fact]
|
||||
public void DefaultInterval_Is1000()
|
||||
{
|
||||
_vm.NewInterval.ShouldBe(1000);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddSubscriptionForNodeAsync adds a subscription.</summary>
|
||||
[Fact]
|
||||
public async Task AddSubscriptionForNodeAsync_AddsSubscription()
|
||||
{
|
||||
@@ -147,6 +159,7 @@ public class SubscriptionsViewModelTests
|
||||
_service.SubscribeCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddSubscriptionForNodeAsync skips duplicate subscriptions.</summary>
|
||||
[Fact]
|
||||
public async Task AddSubscriptionForNodeAsync_SkipsDuplicate()
|
||||
{
|
||||
@@ -159,6 +172,7 @@ public class SubscriptionsViewModelTests
|
||||
_service.SubscribeCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddSubscriptionForNodeAsync does nothing when disconnected.</summary>
|
||||
[Fact]
|
||||
public async Task AddSubscriptionForNodeAsync_DoesNothing_WhenDisconnected()
|
||||
{
|
||||
@@ -170,6 +184,7 @@ public class SubscriptionsViewModelTests
|
||||
_service.SubscribeCallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetSubscribedNodeIds returns all active subscription node IDs.</summary>
|
||||
[Fact]
|
||||
public async Task GetSubscribedNodeIds_ReturnsActiveNodeIds()
|
||||
{
|
||||
@@ -184,6 +199,7 @@ public class SubscriptionsViewModelTests
|
||||
ids.ShouldContain("ns=2;s=Node2");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RestoreSubscriptionsAsync subscribes to all provided node IDs.</summary>
|
||||
[Fact]
|
||||
public async Task RestoreSubscriptionsAsync_SubscribesAllNodes()
|
||||
{
|
||||
@@ -195,6 +211,7 @@ public class SubscriptionsViewModelTests
|
||||
_service.SubscribeCallCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ValidateAndWriteAsync returns true on successful write.</summary>
|
||||
[Fact]
|
||||
public async Task ValidateAndWriteAsync_SuccessReturnsTrue()
|
||||
{
|
||||
@@ -209,6 +226,7 @@ public class SubscriptionsViewModelTests
|
||||
_service.WriteCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ValidateAndWriteAsync returns false when value parsing fails.</summary>
|
||||
[Fact]
|
||||
public async Task ValidateAndWriteAsync_ParseFailureReturnsFalse()
|
||||
{
|
||||
@@ -223,6 +241,7 @@ public class SubscriptionsViewModelTests
|
||||
_service.WriteCallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ValidateAndWriteAsync returns false when write fails.</summary>
|
||||
[Fact]
|
||||
public async Task ValidateAndWriteAsync_WriteFailureReturnsFalse()
|
||||
{
|
||||
@@ -236,6 +255,7 @@ public class SubscriptionsViewModelTests
|
||||
message.ShouldContain("Access denied");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ValidateAndWriteAsync returns false when status is bad.</summary>
|
||||
[Fact]
|
||||
public async Task ValidateAndWriteAsync_BadStatusReturnsFalse()
|
||||
{
|
||||
@@ -249,6 +269,7 @@ public class SubscriptionsViewModelTests
|
||||
message.ShouldContain("Write failed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddSubscriptionRecursiveAsync subscribes a variable directly.</summary>
|
||||
[Fact]
|
||||
public async Task AddSubscriptionRecursiveAsync_SubscribesVariableDirectly()
|
||||
{
|
||||
@@ -260,6 +281,7 @@ public class SubscriptionsViewModelTests
|
||||
_vm.ActiveSubscriptions[0].NodeId.ShouldBe("ns=2;s=Var1");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddSubscriptionRecursiveAsync browses objects and subscribes variable children.</summary>
|
||||
[Fact]
|
||||
public async Task AddSubscriptionRecursiveAsync_BrowsesObjectAndSubscribesVariableChildren()
|
||||
{
|
||||
@@ -311,6 +333,7 @@ public class SubscriptionsViewModelTests
|
||||
_vm.StatusMessage.ShouldContain("Bad node id");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddSubscriptionRecursiveAsync recurses through nested objects.</summary>
|
||||
[Fact]
|
||||
public async Task AddSubscriptionRecursiveAsync_RecursesNestedObjects()
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
|
||||
public sealed class HoconLoaderTests
|
||||
{
|
||||
/// <summary>Verifies that LoadBaseConfig returns a non-empty string.</summary>
|
||||
[Fact]
|
||||
public void LoadBaseConfig_returns_nonempty_string()
|
||||
{
|
||||
@@ -13,6 +14,7 @@ public sealed class HoconLoaderTests
|
||||
hocon.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that base config parses to cluster provider.</summary>
|
||||
[Fact]
|
||||
public void Base_config_parses_to_cluster_provider()
|
||||
{
|
||||
@@ -20,6 +22,7 @@ public sealed class HoconLoaderTests
|
||||
cfg.GetString("akka.actor.provider").ShouldBe("cluster");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that split-brain resolver is set to keep-oldest.</summary>
|
||||
[Fact]
|
||||
public void Split_brain_resolver_is_keep_oldest()
|
||||
{
|
||||
@@ -27,6 +30,7 @@ public sealed class HoconLoaderTests
|
||||
cfg.GetString("akka.cluster.split-brain-resolver.active-strategy").ShouldBe("keep-oldest");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stable-after is configured to 15 seconds.</summary>
|
||||
[Fact]
|
||||
public void Stable_after_is_15_seconds()
|
||||
{
|
||||
@@ -35,6 +39,7 @@ public sealed class HoconLoaderTests
|
||||
.ShouldBe(TimeSpan.FromSeconds(15));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that failure detector threshold is set to 10.</summary>
|
||||
[Fact]
|
||||
public void Failure_detector_threshold_is_10()
|
||||
{
|
||||
@@ -42,6 +47,7 @@ public sealed class HoconLoaderTests
|
||||
cfg.GetDouble("akka.cluster.failure-detector.threshold").ShouldBe(10.0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that OPC UA synchronized dispatcher is configured as pinned.</summary>
|
||||
[Fact]
|
||||
public void Opcua_synchronized_dispatcher_is_pinned()
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
|
||||
public sealed class RoleParserTests
|
||||
{
|
||||
/// <summary>Verifies that empty input yields an empty array.</summary>
|
||||
/// <param name="raw">The raw input string to test (null, empty, or whitespace).</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -14,36 +16,42 @@ public sealed class RoleParserTests
|
||||
RoleParser.Parse(raw).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a single role 'admin' is parsed correctly.</summary>
|
||||
[Fact]
|
||||
public void Single_role_admin()
|
||||
{
|
||||
RoleParser.Parse("admin").ShouldBe(new[] { "admin" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that two roles separated by comma are parsed correctly.</summary>
|
||||
[Fact]
|
||||
public void Two_roles_csv()
|
||||
{
|
||||
RoleParser.Parse("admin,driver").ShouldBe(new[] { "admin", "driver" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parser tolerates surrounding whitespace.</summary>
|
||||
[Fact]
|
||||
public void Whitespace_tolerant()
|
||||
{
|
||||
RoleParser.Parse(" admin , driver ").ShouldBe(new[] { "admin", "driver" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parser is case-insensitive and normalizes to lowercase.</summary>
|
||||
[Fact]
|
||||
public void Case_insensitive_normalizes_to_lower()
|
||||
{
|
||||
RoleParser.Parse("ADMIN,Driver").ShouldBe(new[] { "admin", "driver" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that duplicate roles are removed.</summary>
|
||||
[Fact]
|
||||
public void Duplicate_roles_deduped()
|
||||
{
|
||||
RoleParser.Parse("admin,admin,driver").ShouldBe(new[] { "admin", "driver" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parser throws for unknown roles.</summary>
|
||||
[Fact]
|
||||
public void Unknown_role_throws()
|
||||
{
|
||||
|
||||
@@ -14,8 +14,11 @@ public sealed class AuthorizationTests
|
||||
{
|
||||
private readonly SchemaComplianceFixture _fixture;
|
||||
|
||||
/// <summary>Initializes a new instance of AuthorizationTests with the schema compliance fixture.</summary>
|
||||
/// <param name="fixture">The schema compliance test fixture.</param>
|
||||
public AuthorizationTests(SchemaComplianceFixture fixture) => _fixture = fixture;
|
||||
|
||||
/// <summary>Verifies that the Node role can execute GetCurrentGenerationForCluster but not PublishGeneration.</summary>
|
||||
[Fact]
|
||||
public void Node_role_can_execute_GetCurrentGenerationForCluster_but_not_PublishGeneration()
|
||||
{
|
||||
@@ -48,6 +51,7 @@ public sealed class AuthorizationTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Node role cannot SELECT from configuration tables directly.</summary>
|
||||
[Fact]
|
||||
public void Node_role_cannot_SELECT_from_tables_directly()
|
||||
{
|
||||
@@ -73,6 +77,7 @@ public sealed class AuthorizationTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Admin role can execute PublishGeneration.</summary>
|
||||
[Fact]
|
||||
public void Admin_role_can_execute_PublishGeneration()
|
||||
{
|
||||
|
||||
@@ -9,6 +9,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftValidatorTests
|
||||
{
|
||||
/// <summary>Verifies that UnsSegment validation rejects uppercase and special characters.</summary>
|
||||
/// <param name="name">The segment name to validate.</param>
|
||||
/// <param name="shouldPass">Whether the validation should pass for this name.</param>
|
||||
[Theory]
|
||||
[InlineData("valid-name", true)]
|
||||
[InlineData("line-01", true)]
|
||||
@@ -41,6 +44,7 @@ public sealed class DraftValidatorTests
|
||||
hasUnsError.ShouldBe(!shouldPass);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that namespace cannot be bound to a different cluster.</summary>
|
||||
[Fact]
|
||||
public void Cross_cluster_namespace_binding_is_rejected()
|
||||
{
|
||||
@@ -55,6 +59,7 @@ public sealed class DraftValidatorTests
|
||||
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that namespace in the same cluster is accepted.</summary>
|
||||
[Fact]
|
||||
public void Same_cluster_namespace_binding_is_accepted()
|
||||
{
|
||||
@@ -68,6 +73,7 @@ public sealed class DraftValidatorTests
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "BadCrossClusterNamespaceBinding");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that equipment UUID must remain immutable across generations.</summary>
|
||||
[Fact]
|
||||
public void EquipmentUuid_change_across_generations_is_rejected()
|
||||
{
|
||||
@@ -85,6 +91,7 @@ public sealed class DraftValidatorTests
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentUuidImmutable");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a ZTag cannot be reserved by a different equipment UUID.</summary>
|
||||
[Fact]
|
||||
public void ZTag_reserved_by_different_uuid_is_rejected()
|
||||
{
|
||||
@@ -101,6 +108,7 @@ public sealed class DraftValidatorTests
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "BadDuplicateExternalIdentifier");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that equipment ID must be derived from its UUID.</summary>
|
||||
[Fact]
|
||||
public void EquipmentId_that_does_not_match_derivation_is_rejected()
|
||||
{
|
||||
@@ -114,6 +122,7 @@ public sealed class DraftValidatorTests
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Galaxy driver cannot be placed in Equipment namespace.</summary>
|
||||
[Fact]
|
||||
public void Galaxy_driver_in_Equipment_namespace_is_rejected()
|
||||
{
|
||||
@@ -127,6 +136,7 @@ public sealed class DraftValidatorTests
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that all validation errors are reported simultaneously.</summary>
|
||||
[Fact]
|
||||
public void Draft_with_three_violations_surfaces_all_three()
|
||||
{
|
||||
@@ -150,6 +160,11 @@ public sealed class DraftValidatorTests
|
||||
// Phase 6.3 task #148 part 2 — ValidateClusterTopology
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that cluster topology validation checks node count against declared redundancy mode.</summary>
|
||||
/// <param name="nodeCount">The declared cluster node count.</param>
|
||||
/// <param name="mode">The declared redundancy mode.</param>
|
||||
/// <param name="enabledNodes">The number of enabled nodes to include in the topology.</param>
|
||||
/// <param name="expectedDeclaredErrors">The number of ClusterRedundancyModeInvalid errors expected.</param>
|
||||
[Theory]
|
||||
[InlineData(1, RedundancyMode.None, 1, 0)] // single-node standalone — ok
|
||||
[InlineData(2, RedundancyMode.Warm, 2, 0)] // 2-node warm — ok
|
||||
@@ -168,6 +183,7 @@ public sealed class DraftValidatorTests
|
||||
errors.Count(e => e.Code == "ClusterRedundancyModeInvalid").ShouldBe(expectedDeclaredErrors);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disabled nodes cause topology validation to fail.</summary>
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_flags_disabled_node_mismatch()
|
||||
{
|
||||
@@ -188,6 +204,7 @@ public sealed class DraftValidatorTests
|
||||
// ValidateClusterTopology_flags_multiple_Primary test (and the
|
||||
// ClusterMultiplePrimary error code it asserted) were removed alongside Task 14d.
|
||||
|
||||
/// <summary>Verifies that a valid standalone cluster passes validation.</summary>
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_returns_no_errors_on_valid_standalone()
|
||||
{
|
||||
@@ -227,6 +244,7 @@ public sealed class DraftValidatorTests
|
||||
// ValidatePathLength — Enterprise/Site length precision (Configuration-003)
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that path length validation uses actual Enterprise and Site lengths.</summary>
|
||||
[Fact]
|
||||
public void PathLength_uses_actual_Enterprise_Site_when_provided()
|
||||
{
|
||||
@@ -270,6 +288,7 @@ public sealed class DraftValidatorTests
|
||||
"actual Enterprise='zb' + Site='s' keeps total path at 161 chars — under the 200-char limit");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that path length validation uses conservative fallback when Enterprise and Site are absent.</summary>
|
||||
[Fact]
|
||||
public void PathLength_conservative_fallback_when_Enterprise_Site_absent()
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
|
||||
{
|
||||
/// <summary>Verifies that the composite key allows the same host across different nodes or drivers.</summary>
|
||||
[Fact]
|
||||
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
|
||||
{
|
||||
@@ -56,6 +57,7 @@ public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
|
||||
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the upsert pattern updates existing records in place.</summary>
|
||||
[Fact]
|
||||
public async Task Upsert_pattern_for_same_key_updates_in_place()
|
||||
{
|
||||
@@ -94,6 +96,7 @@ public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
|
||||
(await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the State enum is persisted as a string, not an integer.</summary>
|
||||
[Fact]
|
||||
public async Task Enum_persists_as_string_not_int()
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-sealed-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>Cleans up temporary directory after test execution.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
@@ -31,6 +32,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
PayloadJson = payload,
|
||||
};
|
||||
|
||||
/// <summary>Verifies that reading a snapshot on first boot with no existing snapshot throws.</summary>
|
||||
[Fact]
|
||||
public async Task FirstBoot_NoSnapshot_ReadThrows()
|
||||
{
|
||||
@@ -40,6 +42,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sealed snapshots can be read back correctly.</summary>
|
||||
[Fact]
|
||||
public async Task SealThenRead_RoundTrips()
|
||||
{
|
||||
@@ -54,6 +57,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
read.PayloadJson.ShouldBe("{\"hello\":\"world\"}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sealed files are marked read-only on disk.</summary>
|
||||
[Fact]
|
||||
public async Task SealedFile_IsReadOnly_OnDisk()
|
||||
{
|
||||
@@ -66,6 +70,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
attrs.HasFlag(FileAttributes.ReadOnly).ShouldBeTrue("sealed file must be read-only");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the current generation pointer advances when a new generation is sealed.</summary>
|
||||
[Fact]
|
||||
public async Task SealingTwoGenerations_PointerAdvances_ToLatest()
|
||||
{
|
||||
@@ -78,6 +83,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
read.GenerationId.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that prior generation files are preserved after a new seal.</summary>
|
||||
[Fact]
|
||||
public async Task PriorGenerationFile_Survives_AfterNewSeal()
|
||||
{
|
||||
@@ -90,6 +96,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
File.Exists(Path.Combine(_root, "cluster-a", "2.db")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading a corrupt sealed file fails safely.</summary>
|
||||
[Fact]
|
||||
public async Task CorruptSealedFile_ReadFailsClosed()
|
||||
{
|
||||
@@ -105,6 +112,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading with a missing sealed file fails safely.</summary>
|
||||
[Fact]
|
||||
public async Task MissingSealedFile_ReadFailsClosed()
|
||||
{
|
||||
@@ -120,6 +128,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading with a corrupt pointer file fails safely.</summary>
|
||||
[Fact]
|
||||
public async Task CorruptPointerFile_ReadFailsClosed()
|
||||
{
|
||||
@@ -133,6 +142,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sealing the same generation twice is idempotent.</summary>
|
||||
[Fact]
|
||||
public async Task SealSameGenerationTwice_IsIdempotent()
|
||||
{
|
||||
@@ -144,6 +154,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
|
||||
read.PayloadJson.ShouldBe("{\"sample\":true}", "sealed file is immutable; second seal no-ops");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that independent clusters do not interfere with each other.</summary>
|
||||
[Fact]
|
||||
public async Task IndependentClusters_DoNotInterfere()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
|
||||
/// <summary>Initializes a new instance of the LdapGroupRoleMappingServiceTests class.</summary>
|
||||
public LdapGroupRoleMappingServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
@@ -20,6 +21,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
_db = new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
/// <summary>Disposes the database context.</summary>
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private LdapGroupRoleMapping Make(string group, AdminRole role, string? clusterId = null, bool? isSystemWide = null) =>
|
||||
@@ -31,6 +33,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
IsSystemWide = isSystemWide ?? (clusterId is null),
|
||||
};
|
||||
|
||||
/// <summary>Verifies that Create sets Id and CreatedAtUtc.</summary>
|
||||
[Fact]
|
||||
public async Task Create_SetsId_AndCreatedAtUtc()
|
||||
{
|
||||
@@ -43,6 +46,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
saved.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Create rejects empty LDAP group.</summary>
|
||||
[Fact]
|
||||
public async Task Create_Rejects_EmptyLdapGroup()
|
||||
{
|
||||
@@ -53,6 +57,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Create rejects system-wide mapping with ClusterId.</summary>
|
||||
[Fact]
|
||||
public async Task Create_Rejects_SystemWide_With_ClusterId()
|
||||
{
|
||||
@@ -63,6 +68,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Create rejects non-system-wide mapping without ClusterId.</summary>
|
||||
[Fact]
|
||||
public async Task Create_Rejects_NonSystemWide_WithoutClusterId()
|
||||
{
|
||||
@@ -73,6 +79,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetByGroups returns only matching grants.</summary>
|
||||
[Fact]
|
||||
public async Task GetByGroups_Returns_MatchingGrants_Only()
|
||||
{
|
||||
@@ -88,6 +95,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
results.Select(r => r.Role).ShouldBe([AdminRole.FleetAdmin, AdminRole.ConfigViewer], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetByGroups returns empty when input is empty.</summary>
|
||||
[Fact]
|
||||
public async Task GetByGroups_Empty_Input_ReturnsEmpty()
|
||||
{
|
||||
@@ -99,6 +107,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
results.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ListAll orders results by group then cluster.</summary>
|
||||
[Fact]
|
||||
public async Task ListAll_Orders_ByGroupThenCluster()
|
||||
{
|
||||
@@ -115,6 +124,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
results[2].LdapGroup.ShouldBe("cn=b,dc=x");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Delete removes the matching row.</summary>
|
||||
[Fact]
|
||||
public async Task Delete_Removes_Matching_Row()
|
||||
{
|
||||
@@ -127,6 +137,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
after.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Delete with unknown Id is a no-op.</summary>
|
||||
[Fact]
|
||||
public async Task Delete_Unknown_Id_IsNoOp()
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-cache-test-{Guid.NewGuid():N}.db");
|
||||
|
||||
/// <summary>Cleans up the temporary database file.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath)) File.Delete(_dbPath);
|
||||
@@ -22,6 +23,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
PayloadJson = $"{{\"g\":{gen}}}",
|
||||
};
|
||||
|
||||
/// <summary>Verifies that payload is preserved through a write-then-read cycle.</summary>
|
||||
[Fact]
|
||||
public async Task Roundtrip_preserves_payload()
|
||||
{
|
||||
@@ -35,6 +37,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
got.PayloadJson.ShouldBe(put.PayloadJson);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetMostRecentAsync returns the latest generation when multiple exist.</summary>
|
||||
[Fact]
|
||||
public async Task GetMostRecent_returns_latest_when_multiple_generations_present()
|
||||
{
|
||||
@@ -46,6 +49,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
got!.GenerationId.ShouldBe(20);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetMostRecentAsync returns null for an unknown cluster.</summary>
|
||||
[Fact]
|
||||
public async Task GetMostRecent_returns_null_for_unknown_cluster()
|
||||
{
|
||||
@@ -53,6 +57,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
(await cache.GetMostRecentAsync("ghost")).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Prune keeps the latest N generations and drops older ones.</summary>
|
||||
[Fact]
|
||||
public async Task Prune_keeps_latest_N_and_drops_older()
|
||||
{
|
||||
@@ -75,6 +80,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
count.ShouldBe(10);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing the same cluster/generation twice replaces rather than duplicates.</summary>
|
||||
[Fact]
|
||||
public async Task Put_same_cluster_generation_twice_replaces_not_duplicates()
|
||||
{
|
||||
@@ -95,6 +101,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
// not produce duplicate rows. The original find-then-insert was non-atomic so two racing
|
||||
// callers could both observe `existing is null` and both Insert.
|
||||
// ------------------------------------------------------------------------------------
|
||||
/// <summary>Verifies that concurrent PutAsync calls for the same cluster and generation do not create duplicates.</summary>
|
||||
[Fact]
|
||||
public async Task PutAsync_concurrent_for_same_cluster_and_generation_does_not_duplicate()
|
||||
{
|
||||
@@ -122,6 +129,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
$"PutAsync must upsert atomically — found {gen42Count} rows for (c-1, gen=42) after 64 concurrent puts");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a corrupted cache file surfaces as LocalConfigCacheCorruptException.</summary>
|
||||
[Fact]
|
||||
public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException()
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NodePermissionsTests
|
||||
{
|
||||
/// <summary>Verifies that the underlying type is int so it matches HasConversion in DbContext.</summary>
|
||||
[Fact]
|
||||
public void Underlying_type_is_int_so_it_matches_HasConversion_in_DbContext()
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public sealed class NodePermissionsTests
|
||||
typeof(NodePermissions).GetEnumUnderlyingType().ShouldBe(typeof(int));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that all defined bits round trip through int cast without loss.</summary>
|
||||
[Fact]
|
||||
public void All_defined_bits_round_trip_through_int_cast_without_loss()
|
||||
{
|
||||
@@ -35,6 +37,7 @@ public sealed class NodePermissionsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that bitwise combinations round trip through int storage.</summary>
|
||||
[Fact]
|
||||
public void Bitwise_combinations_round_trip_through_int_storage()
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
private static Microsoft.EntityFrameworkCore.Metadata.IModel DesignModel(OtOpcUaConfigDbContext ctx)
|
||||
=> ctx.GetService<IDesignTimeModel>().Model;
|
||||
|
||||
/// <summary>Verifies that the Script entity is registered with the expected table name and columns.</summary>
|
||||
[Fact]
|
||||
public void Script_entity_registered_with_expected_table_and_columns()
|
||||
{
|
||||
@@ -46,6 +47,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
.GetMaxLength().ShouldBe(16);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Script entity has a unique logical ID constraint.</summary>
|
||||
[Fact]
|
||||
public void Script_has_unique_logical_id()
|
||||
{
|
||||
@@ -55,6 +57,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
entity.GetIndexes().Any(i => i.IsUnique).ShouldBeTrue("Script needs at least one unique index");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the VirtualTag entity is registered with the expected trigger check constraint.</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_entity_registered_with_trigger_check_constraint()
|
||||
{
|
||||
@@ -67,6 +70,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
checks.ShouldContain("CK_VirtualTag_TimerInterval_Min");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the VirtualTag entity has a unique index on its logical key.</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_has_unique_index_on_logical_key()
|
||||
{
|
||||
@@ -76,6 +80,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
entity.GetIndexes().Any(i => i.IsUnique).ShouldBeTrue("VirtualTag needs at least one unique index");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the VirtualTag entity has ChangeTriggered, Historize, and TimerIntervalMs properties.</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_has_ChangeTriggered_and_Historize_flags()
|
||||
{
|
||||
@@ -89,6 +94,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
.ClrType.ShouldBe(typeof(int?));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the ScriptedAlarm entity is registered with severity and type check constraints.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarm_entity_registered_with_severity_and_type_checks()
|
||||
{
|
||||
@@ -101,6 +107,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
checks.ShouldContain("CK_ScriptedAlarm_AlarmType");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ScriptedAlarm has HistorizeToAveva defaulted to true per plan decision 15.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarm_has_HistorizeToAveva_default_true_per_plan_decision_15()
|
||||
{
|
||||
@@ -120,6 +127,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
alarm.Enabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ScriptedAlarmState is keyed on ScriptedAlarmId and not generation-scoped.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_keyed_on_ScriptedAlarmId_not_generation_scoped()
|
||||
{
|
||||
@@ -136,6 +144,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
"ack state follows alarm identity across generations");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ScriptedAlarmState default values match Part 9 initial states.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_default_state_values_match_Part9_initial_states()
|
||||
{
|
||||
@@ -152,6 +161,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
state.LastAckUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ScriptedAlarmState has a JSON check constraint on CommentsJson.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_has_JSON_check_constraint_on_CommentsJson()
|
||||
{
|
||||
@@ -161,6 +171,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
checks.ShouldContain("CK_ScriptedAlarmState_CommentsJson_IsJson");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that all new Phase 7 entities are exposed via DbSet properties.</summary>
|
||||
[Fact]
|
||||
public void All_new_entities_exposed_via_DbSet()
|
||||
{
|
||||
@@ -171,6 +182,7 @@ public sealed class Phase7ScriptingEntitiesTests
|
||||
ctx.ScriptedAlarmStates.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the AddPhase7ScriptingTables migration exists in the assembly.</summary>
|
||||
[Fact]
|
||||
public void AddPhase7ScriptingTables_migration_exists_in_assembly()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-reader-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>Disposes temporary test files.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
@@ -24,6 +25,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that successful central DB reads return value and mark fresh.</summary>
|
||||
[Fact]
|
||||
public async Task CentralDbSucceeds_ReturnsValue_MarksFresh()
|
||||
{
|
||||
@@ -42,6 +44,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
flag.IsStale.ShouldBeFalse("successful central-DB read clears stale flag");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that exhausted retries fall back to cache and mark stale.</summary>
|
||||
[Fact]
|
||||
public async Task CentralDbFails_ExhaustsRetries_FallsBackToCache_MarksStale()
|
||||
{
|
||||
@@ -74,6 +77,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
flag.IsStale.ShouldBeTrue("cache fallback flips stale flag true");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DB failure with unavailable cache throws.</summary>
|
||||
[Fact]
|
||||
public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws()
|
||||
{
|
||||
@@ -94,6 +98,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
flag.IsStale.ShouldBeFalse("no snapshot ever served, so flag stays whatever it was");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cancellation is not retried.</summary>
|
||||
[Fact]
|
||||
public async Task Cancellation_NotRetried()
|
||||
{
|
||||
@@ -127,6 +132,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
// must fall back to the sealed cache, not propagate as caller cancellation.
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that command timeout TaskCanceledException falls back to cache.</summary>
|
||||
[Fact]
|
||||
public async Task CommandTimeout_TaskCanceledException_FallsBackToCache()
|
||||
{
|
||||
@@ -156,6 +162,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
flag.IsStale.ShouldBeTrue("cache fallback marks the stale flag");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Polly timeout rejection falls back to cache.</summary>
|
||||
[Fact]
|
||||
public async Task PollyTimeout_TimeoutRejectedException_FallsBackToCache()
|
||||
{
|
||||
@@ -193,6 +200,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
// exception chain). Project rule: no credential or connection-string fragment in logs.
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that fallback warnings do not log exceptions or password fragments.</summary>
|
||||
[Fact]
|
||||
public async Task FallbackWarning_does_not_log_full_exception_object_or_password_fragment()
|
||||
{
|
||||
@@ -233,6 +241,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
warning.RenderedMessage.ShouldNotContain("User Id=", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that caller cancellation propagates rather than falling back.</summary>
|
||||
[Fact]
|
||||
public async Task CallerCancellation_Propagates_NotFallback()
|
||||
{
|
||||
@@ -267,22 +276,45 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Represents a captured log record for testing.</summary>
|
||||
internal sealed record LogRecord(LogLevel LogLevel, string RenderedMessage, Exception? Exception);
|
||||
|
||||
/// <summary>Captures log records for assertion in tests.</summary>
|
||||
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
/// <summary>Gets the list of captured log records.</summary>
|
||||
public List<LogRecord> Records { get; } = new();
|
||||
|
||||
/// <summary>Begins a scope (no-op for testing).</summary>
|
||||
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||
/// <param name="state">The scope state.</param>
|
||||
/// <returns>A disposable scope handle.</returns>
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
/// <summary>Returns true to enable all log levels.</summary>
|
||||
/// <param name="logLevel">The log level to check.</param>
|
||||
/// <returns>True to indicate the log level is enabled.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Logs a message by capturing it.</summary>
|
||||
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||
/// <param name="logLevel">The log level.</param>
|
||||
/// <param name="eventId">The event identifier.</param>
|
||||
/// <param name="state">The log state.</param>
|
||||
/// <param name="exception">The exception, if any.</param>
|
||||
/// <param name="formatter">Function to format the log message.</param>
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Records.Add(new LogRecord(logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
|
||||
/// <summary>No-op scope for testing.</summary>
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
/// <summary>Gets the singleton instance.</summary>
|
||||
public static readonly NullScope Instance = new();
|
||||
|
||||
/// <summary>Disposes the scope (no-op).</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -290,12 +322,14 @@ internal sealed class CapturingLogger<T> : ILogger<T>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StaleConfigFlagTests
|
||||
{
|
||||
/// <summary>Verifies that default state is fresh.</summary>
|
||||
[Fact]
|
||||
public void Default_IsFresh()
|
||||
{
|
||||
new StaleConfigFlag().IsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stale and fresh states toggle correctly.</summary>
|
||||
[Fact]
|
||||
public void MarkStale_ThenFresh_Toggles()
|
||||
{
|
||||
@@ -306,6 +340,7 @@ public sealed class StaleConfigFlagTests
|
||||
flag.IsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent writes converge to the final state.</summary>
|
||||
[Fact]
|
||||
public void ConcurrentWrites_Converge()
|
||||
{
|
||||
|
||||
@@ -16,9 +16,12 @@ public sealed class SchemaComplianceFixture : IDisposable
|
||||
private const string DefaultServer = "10.100.0.35,14330";
|
||||
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
|
||||
|
||||
/// <summary>Gets the name of the test database.</summary>
|
||||
public string DatabaseName { get; }
|
||||
/// <summary>Gets the connection string for the test database.</summary>
|
||||
public string ConnectionString { get; }
|
||||
|
||||
/// <summary>Initializes a new instance of the SchemaComplianceFixture class.</summary>
|
||||
public SchemaComplianceFixture()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
|
||||
@@ -36,6 +39,8 @@ public sealed class SchemaComplianceFixture : IDisposable
|
||||
ctx.Database.Migrate();
|
||||
}
|
||||
|
||||
/// <summary>Opens a new SQL connection to the test database.</summary>
|
||||
/// <returns>An open SQL connection.</returns>
|
||||
public SqlConnection OpenConnection()
|
||||
{
|
||||
var conn = new SqlConnection(ConnectionString);
|
||||
@@ -43,6 +48,7 @@ public sealed class SchemaComplianceFixture : IDisposable
|
||||
return conn;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the fixture and drops the test database.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
var masterConnection =
|
||||
|
||||
@@ -16,8 +16,11 @@ public sealed class SchemaComplianceTests
|
||||
{
|
||||
private readonly SchemaComplianceFixture _fixture;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="SchemaComplianceTests"/> class.</summary>
|
||||
/// <param name="fixture">The database schema compliance fixture.</param>
|
||||
public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture;
|
||||
|
||||
/// <summary>Verifies that all expected tables exist in the schema.</summary>
|
||||
[Fact]
|
||||
public void All_expected_tables_exist()
|
||||
{
|
||||
@@ -47,6 +50,7 @@ SELECT name FROM sys.tables WHERE name <> '__EFMigrationsHistory' ORDER BY name;
|
||||
actual.Count.ShouldBe(expected.Length);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that filtered unique indexes match the schema specification.</summary>
|
||||
[Fact]
|
||||
public void Filtered_unique_indexes_match_schema_spec()
|
||||
{
|
||||
@@ -73,6 +77,7 @@ WHERE i.is_unique = 1 AND i.has_filter = 1;",
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that check constraints match the schema specification.</summary>
|
||||
[Fact]
|
||||
public void Check_constraints_match_schema_spec()
|
||||
{
|
||||
@@ -94,6 +99,7 @@ WHERE i.is_unique = 1 AND i.has_filter = 1;",
|
||||
actual.ShouldContain(ck, $"missing CHECK constraint: {ck}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that JSON check constraints use the ISJSON function.</summary>
|
||||
[Fact]
|
||||
public void Json_check_constraints_use_IsJson_function()
|
||||
{
|
||||
@@ -110,6 +116,7 @@ WHERE cc.name LIKE 'CK_%_IsJson';",
|
||||
$"{name} definition does not call ISJSON: {definition}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Deployment Status column exists.</summary>
|
||||
[Fact]
|
||||
public void Deployment_Status_column_exists()
|
||||
{
|
||||
@@ -125,6 +132,7 @@ WHERE c.TABLE_NAME = 'Deployment' AND c.COLUMN_NAME = 'Status';",
|
||||
rows.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Equipment carries OPC 4.0 10 identity fields.</summary>
|
||||
[Fact]
|
||||
public void Equipment_carries_Opc40010_identity_fields()
|
||||
{
|
||||
@@ -140,6 +148,7 @@ SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Equipment
|
||||
columns.ShouldContain(col, $"Equipment missing expected column: {col}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Namespace has at least one unique index.</summary>
|
||||
[Fact]
|
||||
public void Namespace_has_some_unique_index()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Alarms;
|
||||
/// </summary>
|
||||
public sealed class AlarmConditionInfoTests
|
||||
{
|
||||
/// <summary>Verifies that the legacy three-argument constructor still compiles and defaults refs to null.</summary>
|
||||
[Fact]
|
||||
public void LegacyThreeArgConstructor_StillCompiles_AndDefaultsRefsToNull()
|
||||
{
|
||||
@@ -30,6 +31,7 @@ public sealed class AlarmConditionInfoTests
|
||||
info.AckMsgWriteRef.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the full constructor populates all five sub-attribute references.</summary>
|
||||
[Fact]
|
||||
public void FullConstructor_PopulatesAllFiveSubAttributeRefs()
|
||||
{
|
||||
@@ -50,6 +52,7 @@ public sealed class AlarmConditionInfoTests
|
||||
info.AckMsgWriteRef.ShouldBe("Tank1.HiAlarm.AckMsg");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that record equality compares all eight fields.</summary>
|
||||
[Fact]
|
||||
public void RecordEquality_ComparesAllEightFields()
|
||||
{
|
||||
@@ -66,6 +69,7 @@ public sealed class AlarmConditionInfoTests
|
||||
a.ShouldBe(b);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that records are distinct when any reference differs.</summary>
|
||||
[Fact]
|
||||
public void RecordEquality_DistinctWhenAnyRefDiffers()
|
||||
{
|
||||
@@ -78,6 +82,7 @@ public sealed class AlarmConditionInfoTests
|
||||
baseInfo.ShouldNotBe(differingAckRef);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with-expressions allow partial updates.</summary>
|
||||
[Fact]
|
||||
public void WithExpression_AllowsPartialUpdates()
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class DriverHealthTests
|
||||
/// all carry a non-null message. The old XML doc "null when state is Healthy" was wrong;
|
||||
/// this test makes the type's actual contract explicit so future doc churn cannot drift.
|
||||
/// </summary>
|
||||
/// <param name="state">The driver state to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverState.Unknown)]
|
||||
[InlineData(DriverState.Initializing)]
|
||||
@@ -36,6 +37,7 @@ public sealed class DriverHealthTests
|
||||
healthWithoutError.State.ShouldBe(state);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DriverState enum contains all expected members.</summary>
|
||||
[Fact]
|
||||
public void DriverState_EnumContainsExpectedMembers()
|
||||
{
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed class DriverTypeRegistryTests
|
||||
TagConfigJsonSchema: "{\"type\": \"object\"}",
|
||||
Tier: tier);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Register followed by Get round-trips metadata correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Register_ThenGet_RoundTrips()
|
||||
{
|
||||
@@ -27,6 +30,10 @@ public sealed class DriverTypeRegistryTests
|
||||
registry.Get("Modbus").ShouldBe(metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Register accepts and preserves non-null tier values.
|
||||
/// </summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
@@ -41,6 +48,9 @@ public sealed class DriverTypeRegistryTests
|
||||
registry.Get(metadata.TypeName).Tier.ShouldBe(tier);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Get is case-insensitive when looking up driver types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Get_IsCaseInsensitive()
|
||||
{
|
||||
@@ -51,6 +61,9 @@ public sealed class DriverTypeRegistryTests
|
||||
registry.Get("GALAXY").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Get throws when looking up an unknown type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Get_UnknownType_Throws()
|
||||
{
|
||||
@@ -60,6 +73,9 @@ public sealed class DriverTypeRegistryTests
|
||||
Should.Throw<KeyNotFoundException>(() => registry.Get("UnregisteredType"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that TryGet returns null when looking up an unknown type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryGet_UnknownType_ReturnsNull()
|
||||
{
|
||||
@@ -69,6 +85,9 @@ public sealed class DriverTypeRegistryTests
|
||||
registry.TryGet("UnregisteredType").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Register throws when attempting to register a duplicate type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Register_DuplicateType_Throws()
|
||||
{
|
||||
@@ -78,6 +97,9 @@ public sealed class DriverTypeRegistryTests
|
||||
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("Modbus")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that duplicate type detection is case-insensitive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Register_DuplicateTypeIsCaseInsensitive()
|
||||
{
|
||||
@@ -87,6 +109,9 @@ public sealed class DriverTypeRegistryTests
|
||||
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("modbus")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that All returns all registered driver types.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void All_ReturnsRegisteredTypes()
|
||||
{
|
||||
@@ -101,6 +126,9 @@ public sealed class DriverTypeRegistryTests
|
||||
all.Select(m => m.TypeName).ShouldBe(new[] { "Modbus", "S7", "Galaxy" }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that NamespaceKindCompatibility flags are implemented as a bitmask.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NamespaceKindCompatibility_FlagsAreBitmask()
|
||||
{
|
||||
@@ -112,6 +140,10 @@ public sealed class DriverTypeRegistryTests
|
||||
both.HasFlag(NamespaceKindCompatibility.Simulated).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Get rejects empty or null type names.
|
||||
/// </summary>
|
||||
/// <param name="typeName">The type name to test (null, empty, or whitespace).</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
|
||||
+19
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
|
||||
/// </summary>
|
||||
public sealed class IHistorianDataSourceContractTests
|
||||
{
|
||||
/// <summary>Verifies that the interface lives in the root namespace.</summary>
|
||||
[Fact]
|
||||
public void Interface_LivesInRootNamespace()
|
||||
{
|
||||
@@ -18,6 +19,7 @@ public sealed class IHistorianDataSourceContractTests
|
||||
.ShouldBe("ZB.MOM.WW.OtOpcUa.Core.Abstractions");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the interface is public.</summary>
|
||||
[Fact]
|
||||
public void Interface_IsPublic()
|
||||
{
|
||||
@@ -25,6 +27,7 @@ public sealed class IHistorianDataSourceContractTests
|
||||
typeof(IHistorianDataSource).IsInterface.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the interface extends IDisposable.</summary>
|
||||
[Fact]
|
||||
public void Interface_ExtendsIDisposable()
|
||||
{
|
||||
@@ -32,6 +35,9 @@ public sealed class IHistorianDataSourceContractTests
|
||||
.ShouldBeTrue("data sources own backend connections; the server disposes them on shutdown");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that read methods return the expected task shape.</summary>
|
||||
/// <param name="methodName">The name of the interface method to inspect.</param>
|
||||
/// <param name="expectedReturnType">The expected Task return type of the method.</param>
|
||||
[Theory]
|
||||
[InlineData("ReadRawAsync", typeof(Task<HistoryReadResult>))]
|
||||
[InlineData("ReadProcessedAsync", typeof(Task<HistoryReadResult>))]
|
||||
@@ -44,6 +50,7 @@ public sealed class IHistorianDataSourceContractTests
|
||||
method!.ReturnType.ShouldBe(expectedReturnType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetHealthSnapshot is synchronous.</summary>
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_IsSynchronous()
|
||||
{
|
||||
@@ -52,6 +59,7 @@ public sealed class IHistorianDataSourceContractTests
|
||||
method!.ReturnType.ShouldBe(typeof(HistorianHealthSnapshot));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HealthSnapshot accepts an empty cluster node list.</summary>
|
||||
[Fact]
|
||||
public void HealthSnapshot_AcceptsEmptyClusterNodeList()
|
||||
{
|
||||
@@ -72,6 +80,7 @@ public sealed class IHistorianDataSourceContractTests
|
||||
snapshot.Nodes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HealthSnapshot preserves cluster nodes.</summary>
|
||||
[Fact]
|
||||
public void HealthSnapshot_PreservesClusterNodes()
|
||||
{
|
||||
@@ -101,6 +110,7 @@ public sealed class IHistorianDataSourceContractTests
|
||||
snapshot.Nodes[0].ShouldBe(node);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ClusterNodeState records are equal by value.</summary>
|
||||
[Fact]
|
||||
public void ClusterNodeState_RecordEqualityByValue()
|
||||
{
|
||||
@@ -110,6 +120,7 @@ public sealed class IHistorianDataSourceContractTests
|
||||
a.ShouldBe(b);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ClusterNodeState instances are distinct by any field.</summary>
|
||||
[Fact]
|
||||
public void ClusterNodeState_DistinctByAnyField()
|
||||
{
|
||||
@@ -127,6 +138,9 @@ public sealed class IHistorianDataSourceContractTests
|
||||
/// (see <c>WonderwareHistorianClient</c> / <c>HistorianDataSource</c> usage). This test
|
||||
/// pins both shapes so accidental changes are caught.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The name of the method to test.</param>
|
||||
/// <param name="parameterName">The name of the parameter to verify.</param>
|
||||
/// <param name="expectedType">The expected parameter type.</param>
|
||||
[Theory]
|
||||
[InlineData("ReadRawAsync", "maxValuesPerNode", typeof(uint))]
|
||||
[InlineData("ReadEventsAsync", "maxEvents", typeof(int))]
|
||||
@@ -139,6 +153,10 @@ public sealed class IHistorianDataSourceContractTests
|
||||
parameter!.ParameterType.ShouldBe(expectedType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that history provider max parameters have the expected type.</summary>
|
||||
/// <param name="methodName">The name of the method to test.</param>
|
||||
/// <param name="parameterName">The name of the parameter to verify.</param>
|
||||
/// <param name="expectedType">The expected parameter type.</param>
|
||||
[Theory]
|
||||
[InlineData("ReadRawAsync", "maxValuesPerNode", typeof(uint))]
|
||||
[InlineData("ReadEventsAsync", "maxEvents", typeof(int))]
|
||||
@@ -159,6 +177,7 @@ public sealed class IHistorianDataSourceContractTests
|
||||
/// historian must implement them). This test pins the asymmetry so an implementer cannot
|
||||
/// accidentally collapse the two surfaces and so the documented rationale stays load-bearing.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The name of the optional method to check.</param>
|
||||
[Theory]
|
||||
[InlineData("ReadAtTimeAsync")]
|
||||
[InlineData("ReadEventsAsync")]
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class InterfaceIndependenceTests
|
||||
{
|
||||
private static readonly Assembly Assembly = typeof(IDriver).Assembly;
|
||||
|
||||
/// <summary>Verifies that the assembly has no references outside the BCL.</summary>
|
||||
[Fact]
|
||||
public void Assembly_HasNoReferencesOutsideBcl()
|
||||
{
|
||||
@@ -37,6 +38,7 @@ public sealed class InterfaceIndependenceTests
|
||||
$"Found disallowed references: {string.Join(", ", disallowed.Select(a => a.Name))}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that all public types live in the root namespace.</summary>
|
||||
[Fact]
|
||||
public void AllPublicTypes_LiveInRootNamespace()
|
||||
{
|
||||
@@ -51,6 +53,8 @@ public sealed class InterfaceIndependenceTests
|
||||
$"Found types in other namespaces: {string.Join(", ", nonRoot.Select(t => $"{t.FullName}"))}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every capability interface is public.</summary>
|
||||
/// <param name="type">The interface type to check.</param>
|
||||
[Theory]
|
||||
[InlineData(typeof(IDriver))]
|
||||
[InlineData(typeof(ITagDiscovery))]
|
||||
|
||||
@@ -10,9 +10,16 @@ public sealed class PollGroupEngineTests
|
||||
{
|
||||
private sealed class FakeSource
|
||||
{
|
||||
/// <summary>Gets the dictionary of test values keyed by reference.</summary>
|
||||
public ConcurrentDictionary<string, object?> Values { get; } = new();
|
||||
|
||||
/// <summary>Gets or sets the number of read operations performed.</summary>
|
||||
public int ReadCount;
|
||||
|
||||
/// <summary>Simulates reading values from the source.</summary>
|
||||
/// <param name="refs">The references to read.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task returning the data value snapshots.</returns>
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> refs, CancellationToken ct)
|
||||
{
|
||||
@@ -27,6 +34,7 @@ public sealed class PollGroupEngineTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the initial poll forces an event for every subscribed tag.</summary>
|
||||
[Fact]
|
||||
public async Task Initial_poll_force_raises_every_subscribed_tag()
|
||||
{
|
||||
@@ -45,6 +53,7 @@ public sealed class PollGroupEngineTests
|
||||
engine.Unsubscribe(handle).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unchanged values are only raised once.</summary>
|
||||
[Fact]
|
||||
public async Task Unchanged_value_raises_only_once()
|
||||
{
|
||||
@@ -62,6 +71,7 @@ public sealed class PollGroupEngineTests
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that value changes raise new events.</summary>
|
||||
[Fact]
|
||||
public async Task Value_change_raises_new_event()
|
||||
{
|
||||
@@ -81,6 +91,7 @@ public sealed class PollGroupEngineTests
|
||||
events.Last().Item3.Value.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribe halts the polling loop.</summary>
|
||||
[Fact]
|
||||
public async Task Unsubscribe_halts_the_loop()
|
||||
{
|
||||
@@ -101,6 +112,7 @@ public sealed class PollGroupEngineTests
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that intervals below the configured floor are clamped.</summary>
|
||||
[Fact]
|
||||
public async Task Interval_below_floor_is_clamped()
|
||||
{
|
||||
@@ -121,6 +133,7 @@ public sealed class PollGroupEngineTests
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple subscriptions operate independently.</summary>
|
||||
[Fact]
|
||||
public async Task Multiple_subscriptions_are_independent()
|
||||
{
|
||||
@@ -151,6 +164,7 @@ public sealed class PollGroupEngineTests
|
||||
engine.Unsubscribe(hb);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reader exceptions do not crash the polling loop.</summary>
|
||||
[Fact]
|
||||
public async Task Reader_exception_does_not_crash_loop()
|
||||
{
|
||||
@@ -180,6 +194,7 @@ public sealed class PollGroupEngineTests
|
||||
events.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing an unknown handle returns false.</summary>
|
||||
[Fact]
|
||||
public async Task Unsubscribe_unknown_handle_returns_false()
|
||||
{
|
||||
@@ -190,6 +205,7 @@ public sealed class PollGroupEngineTests
|
||||
engine.Unsubscribe(foreign).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the active subscription count tracks lifecycle changes.</summary>
|
||||
[Fact]
|
||||
public async Task ActiveSubscriptionCount_tracks_lifecycle()
|
||||
{
|
||||
@@ -208,6 +224,7 @@ public sealed class PollGroupEngineTests
|
||||
engine.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the engine cancels all active subscriptions.</summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_cancels_all_subscriptions()
|
||||
{
|
||||
@@ -446,6 +463,7 @@ public sealed class PollGroupEngineTests
|
||||
|
||||
private sealed record DummyHandle : ISubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets a diagnostic identifier for this handle.</summary>
|
||||
public string DiagnosticId => "dummy";
|
||||
}
|
||||
|
||||
|
||||
+36
@@ -19,12 +19,14 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
private readonly string _dbPath;
|
||||
private readonly ILogger _log;
|
||||
|
||||
/// <summary>Initializes a new test instance.</summary>
|
||||
public SqliteStoreAndForwardSinkTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-historian-{Guid.NewGuid():N}.sqlite");
|
||||
_log = new LoggerConfiguration().MinimumLevel.Verbose().CreateLogger();
|
||||
}
|
||||
|
||||
/// <summary>Cleans up test resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try { if (File.Exists(_dbPath)) File.Delete(_dbPath); } catch { }
|
||||
@@ -32,11 +34,19 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
|
||||
private sealed class FakeWriter : IAlarmHistorianWriter
|
||||
{
|
||||
/// <summary>Gets the queue of outcomes per event.</summary>
|
||||
public Queue<HistorianWriteOutcome> NextOutcomePerEvent { get; } = new();
|
||||
/// <summary>Gets or sets the default outcome for events.</summary>
|
||||
public HistorianWriteOutcome DefaultOutcome { get; set; } = HistorianWriteOutcome.Ack;
|
||||
/// <summary>Gets the batches that have been written.</summary>
|
||||
public List<IReadOnlyList<AlarmHistorianEvent>> Batches { get; } = [];
|
||||
/// <summary>Gets or sets an exception to throw once.</summary>
|
||||
public Exception? ThrowOnce { get; set; }
|
||||
|
||||
/// <summary>Writes a batch of events.</summary>
|
||||
/// <param name="batch">Events to write.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task returning the write outcomes.</returns>
|
||||
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
|
||||
{
|
||||
@@ -66,6 +76,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
Comment: null,
|
||||
TimestampUtc: ts ?? DateTime.UtcNow);
|
||||
|
||||
/// <summary>Verifies that acknowledged events are removed from the queue.</summary>
|
||||
[Fact]
|
||||
public async Task EnqueueThenDrain_Ack_removes_row()
|
||||
{
|
||||
@@ -86,6 +97,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
status.LastSuccessUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that draining an empty queue is a no-op.</summary>
|
||||
[Fact]
|
||||
public async Task Drain_with_empty_queue_is_noop()
|
||||
{
|
||||
@@ -98,6 +110,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
sink.GetStatus().DrainState.ShouldBe(HistorianDrainState.Idle);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RetryPlease outcome bumps backoff and keeps the row queued.</summary>
|
||||
[Fact]
|
||||
public async Task RetryPlease_bumps_backoff_and_keeps_row()
|
||||
{
|
||||
@@ -114,6 +127,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
sink.GetStatus().DrainState.ShouldBe(HistorianDrainState.BackingOff);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an Ack after RetryPlease resets backoff to the floor.</summary>
|
||||
[Fact]
|
||||
public async Task Ack_after_Retry_resets_backoff()
|
||||
{
|
||||
@@ -132,6 +146,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
sink.GetStatus().QueueDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that PermanentFail outcome dead-letters only the failed event.</summary>
|
||||
[Fact]
|
||||
public async Task PermanentFail_dead_letters_one_row_only()
|
||||
{
|
||||
@@ -149,6 +164,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
status.DeadLetterDepth.ShouldBe(1, "bad row dead-lettered");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writer exceptions trigger retry for the entire batch.</summary>
|
||||
[Fact]
|
||||
public async Task Writer_exception_treated_as_retry_for_whole_batch()
|
||||
{
|
||||
@@ -168,6 +184,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
sink.GetStatus().QueueDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that capacity eviction drops the oldest non-dead-lettered row.</summary>
|
||||
[Fact]
|
||||
public async Task Capacity_eviction_drops_oldest_nondeadlettered_row()
|
||||
{
|
||||
@@ -191,6 +208,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
drained.ShouldContain("A4");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that dead-lettered rows are purged after retention period expires.</summary>
|
||||
[Fact]
|
||||
public async Task Deadlettered_rows_are_purged_past_retention()
|
||||
{
|
||||
@@ -214,6 +232,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
sink.GetStatus().DeadLetterDepth.ShouldBe(0, "purged past retention");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RetryDeadLettered requeues dead-lettered rows for retry.</summary>
|
||||
[Fact]
|
||||
public async Task RetryDeadLettered_requeues_for_retry()
|
||||
{
|
||||
@@ -233,6 +252,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
status.DeadLetterDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the exponential backoff ladder caps at 60 seconds.</summary>
|
||||
[Fact]
|
||||
public async Task Backoff_ladder_caps_at_60s()
|
||||
{
|
||||
@@ -248,6 +268,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that NullAlarmHistorianSink reports disabled status.</summary>
|
||||
[Fact]
|
||||
public void NullAlarmHistorianSink_reports_disabled_status()
|
||||
{
|
||||
@@ -256,6 +277,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
s.QueueDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that NullAlarmHistorianSink silently swallows enqueue calls.</summary>
|
||||
[Fact]
|
||||
public async Task NullAlarmHistorianSink_swallows_enqueue()
|
||||
{
|
||||
@@ -263,6 +285,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
await NullAlarmHistorianSink.Instance.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the constructor rejects invalid arguments.</summary>
|
||||
[Fact]
|
||||
public void Ctor_rejects_bad_args()
|
||||
{
|
||||
@@ -274,6 +297,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, capacity: 0));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a disposed sink rejects enqueue operations.</summary>
|
||||
[Fact]
|
||||
public async Task Disposed_sink_rejects_enqueue()
|
||||
{
|
||||
@@ -474,8 +498,13 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
/// <summary>A writer that throws once, then behaves normally — used to prove the drain loop self-heals.</summary>
|
||||
private sealed class ThrowingThenHealingWriter : IAlarmHistorianWriter
|
||||
{
|
||||
/// <summary>Gets the number of times WriteBatchAsync has been called.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <summary>Writes a batch of events, throwing once then recovering.</summary>
|
||||
/// <param name="batch">Events to write.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task returning the write outcomes.</returns>
|
||||
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
|
||||
{
|
||||
@@ -597,9 +626,16 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
{
|
||||
private bool _returnExtra;
|
||||
|
||||
/// <summary>Initializes a new instance.</summary>
|
||||
/// <param name="returnExtraOutcome">Whether to return an extra outcome on the first call.</param>
|
||||
public WrongCardinalityWriter(bool returnExtraOutcome) => _returnExtra = returnExtraOutcome;
|
||||
/// <summary>Fixes the writer to return correct cardinality.</summary>
|
||||
public void FixWriter() => _returnExtra = false;
|
||||
|
||||
/// <summary>Writes a batch of events, returning wrong cardinality until fixed.</summary>
|
||||
/// <param name="batch">Events to write.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task returning the write outcomes.</returns>
|
||||
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -4,19 +4,29 @@ using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
/// <summary>Test fake implementation of <see cref="ITagUpstreamSource"/> for verifying subscription behavior.</summary>
|
||||
public sealed class FakeUpstream : ITagUpstreamSource
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _subs
|
||||
= new(StringComparer.Ordinal);
|
||||
/// <summary>Gets the current count of active subscriptions.</summary>
|
||||
public int ActiveSubscriptionCount { get; private set; }
|
||||
|
||||
/// <summary>Sets a tag value without notifying subscribers.</summary>
|
||||
/// <param name="path">The tag path to set.</param>
|
||||
/// <param name="value">The value to set for the tag.</param>
|
||||
/// <param name="statusCode">The OPC UA status code for the value.</param>
|
||||
public void Set(string path, object? value, uint statusCode = 0u)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
|
||||
}
|
||||
|
||||
/// <summary>Sets a tag value and notifies all current subscribers.</summary>
|
||||
/// <param name="path">The tag path to set.</param>
|
||||
/// <param name="value">The value to set for the tag.</param>
|
||||
/// <param name="statusCode">The OPC UA status code for the value.</param>
|
||||
public void Push(string path, object? value, uint statusCode = 0u)
|
||||
{
|
||||
Set(path, value, statusCode);
|
||||
@@ -28,10 +38,15 @@ public sealed class FakeUpstream : ITagUpstreamSource
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the current value of a tag, or returns a bad-status snapshot if not set.</summary>
|
||||
/// <param name="path">The tag path to read.</param>
|
||||
public DataValueSnapshot ReadTag(string path)
|
||||
=> _values.TryGetValue(path, out var v) ? v
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
|
||||
|
||||
/// <summary>Subscribes an observer to tag changes for the given path.</summary>
|
||||
/// <param name="path">The tag path to subscribe to.</param>
|
||||
/// <param name="observer">The observer callback to invoke on tag changes.</param>
|
||||
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
var list = _subs.GetOrAdd(path, _ => []);
|
||||
@@ -40,13 +55,19 @@ public sealed class FakeUpstream : ITagUpstreamSource
|
||||
return new Unsub(this, path, observer);
|
||||
}
|
||||
|
||||
/// <summary>Disposable subscription handle that unsubscribes the observer when disposed.</summary>
|
||||
private sealed class Unsub : IDisposable
|
||||
{
|
||||
private readonly FakeUpstream _up;
|
||||
private readonly string _path;
|
||||
private readonly Action<string, DataValueSnapshot> _observer;
|
||||
/// <summary>Initializes the unsubscription handle with references needed to clean up the subscription.</summary>
|
||||
/// <param name="up">The upstream source containing the subscription list.</param>
|
||||
/// <param name="path">The tag path to unsubscribe from.</param>
|
||||
/// <param name="observer">The observer to remove from the subscription list.</param>
|
||||
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
|
||||
{ _up = up; _path = path; _observer = observer; }
|
||||
/// <summary>Removes the observer from the subscription list.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_up._subs.TryGetValue(_path, out var list))
|
||||
|
||||
@@ -16,12 +16,14 @@ public sealed class MessageTemplateTests
|
||||
private static DataValueSnapshot? Resolver(Dictionary<string, DataValueSnapshot> map, string path)
|
||||
=> map.TryGetValue(path, out var v) ? v : null;
|
||||
|
||||
/// <summary>Verifies template with no tokens is returned unchanged.</summary>
|
||||
[Fact]
|
||||
public void No_tokens_returns_template_unchanged()
|
||||
{
|
||||
MessageTemplate.Resolve("No tokens here", _ => null).ShouldBe("No tokens here");
|
||||
}
|
||||
|
||||
/// <summary>Verifies single token in template is correctly substituted.</summary>
|
||||
[Fact]
|
||||
public void Single_token_substituted()
|
||||
{
|
||||
@@ -29,6 +31,7 @@ public sealed class MessageTemplateTests
|
||||
MessageTemplate.Resolve("Temp={Tank/Temp}C", p => Resolver(map, p)).ShouldBe("Temp=75.5C");
|
||||
}
|
||||
|
||||
/// <summary>Verifies multiple tokens in template are all substituted.</summary>
|
||||
[Fact]
|
||||
public void Multiple_tokens_substituted()
|
||||
{
|
||||
@@ -40,6 +43,7 @@ public sealed class MessageTemplateTests
|
||||
MessageTemplate.Resolve("{A}/{B}", p => Resolver(map, p)).ShouldBe("10/on");
|
||||
}
|
||||
|
||||
/// <summary>Verifies tokens with bad quality become question marks.</summary>
|
||||
[Fact]
|
||||
public void Bad_quality_token_becomes_question_mark()
|
||||
{
|
||||
@@ -47,12 +51,14 @@ public sealed class MessageTemplateTests
|
||||
MessageTemplate.Resolve("value={Bad}", p => Resolver(map, p)).ShouldBe("value={?}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies unknown token paths become question marks.</summary>
|
||||
[Fact]
|
||||
public void Unknown_path_becomes_question_mark()
|
||||
{
|
||||
MessageTemplate.Resolve("value={DoesNotExist}", _ => null).ShouldBe("value={?}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies null values with good quality become question marks.</summary>
|
||||
[Fact]
|
||||
public void Null_value_with_good_quality_becomes_question_mark()
|
||||
{
|
||||
@@ -60,6 +66,7 @@ public sealed class MessageTemplateTests
|
||||
MessageTemplate.Resolve("{X}", p => Resolver(map, p)).ShouldBe("{?}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies tokens containing slashes and dots are correctly resolved.</summary>
|
||||
[Fact]
|
||||
public void Tokens_with_slashes_and_dots_resolved()
|
||||
{
|
||||
@@ -71,18 +78,21 @@ public sealed class MessageTemplateTests
|
||||
.ShouldBe("rpm=1200");
|
||||
}
|
||||
|
||||
/// <summary>Verifies empty template returns an empty string.</summary>
|
||||
[Fact]
|
||||
public void Empty_template_returns_empty()
|
||||
{
|
||||
MessageTemplate.Resolve("", _ => null).ShouldBe("");
|
||||
}
|
||||
|
||||
/// <summary>Verifies null template returns an empty string without throwing.</summary>
|
||||
[Fact]
|
||||
public void Null_template_returns_empty_without_throwing()
|
||||
{
|
||||
MessageTemplate.Resolve(null!, _ => null).ShouldBe("");
|
||||
}
|
||||
|
||||
/// <summary>Verifies ExtractTokenPaths returns all token paths from a template.</summary>
|
||||
[Fact]
|
||||
public void ExtractTokenPaths_returns_every_distinct_token()
|
||||
{
|
||||
@@ -90,6 +100,7 @@ public sealed class MessageTemplateTests
|
||||
tokens.ShouldBe(new[] { "A", "B", "A", "C" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies ExtractTokenPaths returns empty for templates without tokens.</summary>
|
||||
[Fact]
|
||||
public void ExtractTokenPaths_empty_for_tokenless_template()
|
||||
{
|
||||
@@ -98,6 +109,7 @@ public sealed class MessageTemplateTests
|
||||
MessageTemplate.ExtractTokenPaths(null).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies whitespace inside token is trimmed during resolution.</summary>
|
||||
[Fact]
|
||||
public void Whitespace_inside_token_is_trimmed()
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class Part9StateMachineTests
|
||||
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
private static AlarmConditionState Fresh() => AlarmConditionState.Fresh("alarm-1", T0);
|
||||
|
||||
/// <summary>Verifies that an inactive alarm becomes active and emits Activated when predicate becomes true.</summary>
|
||||
[Fact]
|
||||
public void Predicate_true_on_inactive_becomes_active_and_emits_Activated()
|
||||
{
|
||||
@@ -26,6 +27,7 @@ public sealed class Part9StateMachineTests
|
||||
r.State.LastActiveUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an active alarm becomes inactive and emits Cleared when predicate becomes false.</summary>
|
||||
[Fact]
|
||||
public void Predicate_false_on_active_becomes_inactive_and_emits_Cleared()
|
||||
{
|
||||
@@ -36,6 +38,7 @@ public sealed class Part9StateMachineTests
|
||||
r.State.LastClearedUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that predicate changes with unchanged state emit None.</summary>
|
||||
[Fact]
|
||||
public void Predicate_unchanged_state_emits_None()
|
||||
{
|
||||
@@ -43,6 +46,7 @@ public sealed class Part9StateMachineTests
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a disabled alarm ignores predicate changes.</summary>
|
||||
[Fact]
|
||||
public void Disabled_alarm_ignores_predicate()
|
||||
{
|
||||
@@ -52,6 +56,7 @@ public sealed class Part9StateMachineTests
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledging an unacknowledged alarm records the user and emits Acknowledged.</summary>
|
||||
[Fact]
|
||||
public void Acknowledge_from_unacked_records_user_and_emits()
|
||||
{
|
||||
@@ -64,6 +69,7 @@ public sealed class Part9StateMachineTests
|
||||
r.Emission.ShouldBe(EmissionKind.Acknowledged);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledging an already-acknowledged alarm is a no-op.</summary>
|
||||
[Fact]
|
||||
public void Acknowledge_when_already_acked_is_noop()
|
||||
{
|
||||
@@ -73,6 +79,7 @@ public sealed class Part9StateMachineTests
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledging without a user throws ArgumentException.</summary>
|
||||
[Fact]
|
||||
public void Acknowledge_without_user_throws()
|
||||
{
|
||||
@@ -80,6 +87,7 @@ public sealed class Part9StateMachineTests
|
||||
Part9StateMachine.ApplyAcknowledge(Fresh(), "", null, T0));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that confirming after clear records the user and emits Confirmed.</summary>
|
||||
[Fact]
|
||||
public void Confirm_after_clear_records_user_and_emits()
|
||||
{
|
||||
@@ -95,6 +103,7 @@ public sealed class Part9StateMachineTests
|
||||
r.Emission.ShouldBe(EmissionKind.Confirmed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that one-shot shelve suppresses the next activation emission.</summary>
|
||||
[Fact]
|
||||
public void OneShotShelve_suppresses_next_activation_emission()
|
||||
{
|
||||
@@ -104,6 +113,7 @@ public sealed class Part9StateMachineTests
|
||||
r.Emission.ShouldBe(EmissionKind.Suppressed, "but subscribers don't see it");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that one-shot shelve expires when the alarm clears.</summary>
|
||||
[Fact]
|
||||
public void OneShotShelve_expires_on_clear()
|
||||
{
|
||||
@@ -114,6 +124,7 @@ public sealed class Part9StateMachineTests
|
||||
r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved, "OneShot expires on clear");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that timed shelve requires a future unshelve time.</summary>
|
||||
[Fact]
|
||||
public void TimedShelve_requires_future_unshelve_time()
|
||||
{
|
||||
@@ -121,6 +132,7 @@ public sealed class Part9StateMachineTests
|
||||
Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", T0, T0.AddSeconds(5)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that timed shelve expires via shelving check at the specified time.</summary>
|
||||
[Fact]
|
||||
public void TimedShelve_expires_via_shelving_check()
|
||||
{
|
||||
@@ -140,6 +152,7 @@ public sealed class Part9StateMachineTests
|
||||
after.State.Comments.Any(c => c.Kind == "AutoUnshelve").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unshelving an unshelved alarm is a no-op.</summary>
|
||||
[Fact]
|
||||
public void Unshelve_from_unshelved_is_noop()
|
||||
{
|
||||
@@ -147,6 +160,7 @@ public sealed class Part9StateMachineTests
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that explicit unshelve emits an Unshelved event.</summary>
|
||||
[Fact]
|
||||
public void Explicit_Unshelve_emits_event()
|
||||
{
|
||||
@@ -156,6 +170,7 @@ public sealed class Part9StateMachineTests
|
||||
r.Emission.ShouldBe(EmissionKind.Unshelved);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that adding a comment appends to the audit trail and emits CommentAdded.</summary>
|
||||
[Fact]
|
||||
public void AddComment_appends_to_audit_trail_with_event()
|
||||
{
|
||||
@@ -167,6 +182,7 @@ public sealed class Part9StateMachineTests
|
||||
r.Emission.ShouldBe(EmissionKind.CommentAdded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that comments are append-only and never rewritten.</summary>
|
||||
[Fact]
|
||||
public void Comments_are_append_only_never_rewritten()
|
||||
{
|
||||
@@ -179,6 +195,7 @@ public sealed class Part9StateMachineTests
|
||||
s.Comments[2].User.ShouldBe("carol");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a full alarm lifecycle walk produces all expected emissions.</summary>
|
||||
[Fact]
|
||||
public void Full_lifecycle_walk_produces_every_expected_emission()
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
MessageTemplate: msg,
|
||||
PredicateScriptSource: predicate);
|
||||
|
||||
/// <summary>Verifies that LoadAsync compiles the alarm predicate and subscribes to all referenced upstream tags.</summary>
|
||||
[Fact]
|
||||
public async Task Load_compiles_and_subscribes_to_referenced_upstreams()
|
||||
{
|
||||
@@ -45,6 +46,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compile failures across multiple alarms are aggregated into a single error.</summary>
|
||||
[Fact]
|
||||
public async Task Compile_failures_aggregated_into_one_error()
|
||||
{
|
||||
@@ -60,6 +62,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
ex.Message.ShouldContain("2 alarm(s) did not compile");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an upstream tag change triggers predicate re-evaluation and emits an Activated event.</summary>
|
||||
[Fact]
|
||||
public async Task Upstream_change_re_evaluates_predicate_and_emits_Activated()
|
||||
{
|
||||
@@ -80,6 +83,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that clearing an upstream tag value emits a Cleared event and transitions the alarm to Inactive.</summary>
|
||||
[Fact]
|
||||
public async Task Clearing_upstream_emits_Cleared_event()
|
||||
{
|
||||
@@ -100,6 +104,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the message template resolves current tag values at the moment of alarm emission.</summary>
|
||||
[Fact]
|
||||
public async Task Message_template_resolves_tag_values_at_emission()
|
||||
{
|
||||
@@ -124,6 +129,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
events[0].Message.ShouldBe("Temp 150C exceeded limit 100C");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AcknowledgeAsync records the operator user and persists the ack state to the store.</summary>
|
||||
[Fact]
|
||||
public async Task Ack_records_user_and_persists_to_store()
|
||||
{
|
||||
@@ -143,6 +149,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
persisted.Comments.Any(c => c.Kind == "Acknowledge" && c.User == "alice").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that startup recovery restores the persisted ack state but re-derives the active state from the live predicate.</summary>
|
||||
[Fact]
|
||||
public async Task Startup_recovery_preserves_ack_but_rederives_active_from_predicate()
|
||||
{
|
||||
@@ -190,6 +197,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
s.LastAckUser.ShouldBe("alice");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a shelved alarm transitions its internal state on activation but suppresses the Activated emission.</summary>
|
||||
[Fact]
|
||||
public async Task Shelved_active_transitions_state_but_suppresses_emission()
|
||||
{
|
||||
@@ -213,6 +221,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
"state still advances so startup recovery is consistent");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a runtime exception thrown by a predicate script leaves the alarm state unchanged and does not affect other alarms.</summary>
|
||||
[Fact]
|
||||
public async Task Predicate_runtime_exception_does_not_transition_state()
|
||||
{
|
||||
@@ -229,6 +238,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
eng.GetState("GoodScript")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a disabled alarm does not activate on predicate change and resumes normally after being re-enabled.</summary>
|
||||
[Fact]
|
||||
public async Task Disable_prevents_activation_until_re_enabled()
|
||||
{
|
||||
@@ -249,6 +259,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
await WaitForAsync(() => eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AddCommentAsync appends to the audit trail without changing the alarm's active or ack state.</summary>
|
||||
[Fact]
|
||||
public async Task AddComment_appends_to_audit_without_state_change()
|
||||
{
|
||||
@@ -266,6 +277,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
s.Comments[0].Kind.ShouldBe("AddComment");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that predicate scripts are forbidden from calling SetVirtualTag, and that the exception is isolated without state change.</summary>
|
||||
[Fact]
|
||||
public async Task Predicate_scripts_cannot_SetVirtualTag()
|
||||
{
|
||||
@@ -289,6 +301,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
eng.GetState("Bad")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the engine releases all upstream tag subscriptions.</summary>
|
||||
[Fact]
|
||||
public async Task Dispose_releases_upstream_subscriptions()
|
||||
{
|
||||
@@ -303,6 +316,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
up.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent reads of alarm state during dictionary mutations do not throw (regression for Core.ScriptedAlarms-001).</summary>
|
||||
[Fact]
|
||||
public async Task Concurrent_reads_during_mutation_do_not_throw(/* Core.ScriptedAlarms-001 */)
|
||||
{
|
||||
@@ -371,6 +385,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
// (1) Timed-shelve auto-expiry driven by the engine's shelving timer via an
|
||||
// injectable clock — the clock and scriptTimeout constructor parameters
|
||||
// exist for exactly this.
|
||||
/// <summary>Verifies that a timed shelve automatically expires when the engine's shelving check runs past the unshelve time.</summary>
|
||||
[Fact]
|
||||
public async Task TimedShelve_auto_expires_when_engine_shelving_check_runs(/* -012 (1) */)
|
||||
{
|
||||
@@ -407,6 +422,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
}
|
||||
|
||||
// (2a) ConfirmAsync end-to-end through the engine.
|
||||
/// <summary>Verifies that ConfirmAsync records the confirming user and emits a Confirmed event persisted to the store.</summary>
|
||||
[Fact]
|
||||
public async Task ConfirmAsync_records_user_and_emits_Confirmed(/* -012 (2) */)
|
||||
{
|
||||
@@ -433,6 +449,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
}
|
||||
|
||||
// (2b) TimedShelveAsync / UnshelveAsync end-to-end through the engine.
|
||||
/// <summary>Verifies that TimedShelveAsync shelves with a deadline and UnshelveAsync removes the shelve before the timer expires.</summary>
|
||||
[Fact]
|
||||
public async Task TimedShelveAsync_and_UnshelveAsync_round_trip(/* -012 (2) */)
|
||||
{
|
||||
@@ -460,6 +477,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
}
|
||||
|
||||
// (2c) EnableAsync end-to-end through the engine.
|
||||
/// <summary>Verifies that EnableAsync transitions the alarm back to Enabled state and emits an Enabled event.</summary>
|
||||
[Fact]
|
||||
public async Task EnableAsync_re_enables_after_disable(/* -012 (2) */)
|
||||
{
|
||||
@@ -482,6 +500,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
// (3) OnEvent subscriber-throws isolation — a bad subscriber must not crash
|
||||
// the engine or prevent subsequent alarm state transitions. The engine logs
|
||||
// the exception and continues operating; any later alarm changes still work.
|
||||
/// <summary>Verifies that an exception thrown by an OnEvent subscriber is isolated and does not crash the engine or prevent further state transitions.</summary>
|
||||
[Fact]
|
||||
public async Task OnEvent_subscriber_exception_does_not_crash_engine(/* -012 (3) */)
|
||||
{
|
||||
@@ -514,6 +533,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
|
||||
// (4) IAlarmStateStore.SaveAsync failure — in-memory state must remain at the
|
||||
// prior value after finding -007 fix (persist-before-update).
|
||||
/// <summary>Verifies that a store SaveAsync failure leaves the in-memory alarm state at its prior value (persist-before-update invariant, finding -007).</summary>
|
||||
[Fact]
|
||||
public async Task Store_save_failure_leaves_in_memory_state_unchanged(/* -012 (4) */)
|
||||
{
|
||||
@@ -544,6 +564,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
|
||||
// (5) Re-entrant LoadAsync — the old timer must not keep firing after a second
|
||||
// call (regression for finding -002: _shelvingTimer?.Dispose() fix).
|
||||
/// <summary>Verifies that a second LoadAsync call disposes the prior shelving timer so it does not keep firing after reload (regression for finding -002).</summary>
|
||||
[Fact]
|
||||
public async Task Second_LoadAsync_does_not_leak_old_timer(/* -012 (5) */)
|
||||
{
|
||||
@@ -576,6 +597,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
|
||||
// (6) Cold-start AreInputsReady guard — null value, Bad status, and Uncertain
|
||||
// status inputs are all handled correctly.
|
||||
/// <summary>Verifies that AreInputsReady blocks predicate evaluation when inputs have null values or Bad status codes, while Uncertain quality is accepted.</summary>
|
||||
[Fact]
|
||||
public async Task AreInputsReady_blocks_evaluation_for_null_and_bad_inputs(/* -012 (6) */)
|
||||
{
|
||||
@@ -612,6 +634,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
// (2) A subscriber that re-enters the engine (e.g. AcknowledgeAsync) must
|
||||
// not deadlock against _evalGate. Both regressions are covered here.
|
||||
// -------------------------------------------------------------------------
|
||||
/// <summary>Verifies that an OnEvent subscriber can call engine methods (e.g. AcknowledgeAsync) without deadlocking against the evaluation gate (regression for Core.ScriptedAlarms-003).</summary>
|
||||
[Fact]
|
||||
public async Task OnEvent_subscriber_can_call_back_into_engine_without_deadlock(/* -003 */)
|
||||
{
|
||||
@@ -663,6 +686,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that OnEvent emission occurs after the evaluation gate is released, so subscribers can re-enter the engine without deadlocking (regression for Core.ScriptedAlarms-003).</summary>
|
||||
[Fact]
|
||||
public void OnEvent_emission_happens_outside_evalGate(/* -003 */)
|
||||
{
|
||||
@@ -723,6 +747,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
// or shelving check started just before Dispose can keep running and write
|
||||
// to a (possibly disposed) store after the engine has returned.
|
||||
// -------------------------------------------------------------------------
|
||||
/// <summary>Verifies that Dispose blocks until in-flight background re-evaluation tasks complete, preventing the engine from outliving its store (regression for Core.ScriptedAlarms-006).</summary>
|
||||
[Fact]
|
||||
public async Task Dispose_drains_in_flight_reevaluation_tasks(/* -006 */)
|
||||
{
|
||||
@@ -768,6 +793,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
// resolution renders Uncertain as "{?}" so the operator sees the doubt
|
||||
// explicitly. The two policies are documented in docs/ScriptedAlarms.md.
|
||||
// -------------------------------------------------------------------------
|
||||
/// <summary>Verifies that Uncertain-quality inputs are accepted by the predicate but rendered as "{?}" in the operator-facing message template (Core.ScriptedAlarms-010).</summary>
|
||||
[Fact]
|
||||
public async Task Uncertain_quality_drives_predicate_but_renders_question_mark_in_message(/* -010 */)
|
||||
{
|
||||
@@ -815,6 +841,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
// (which still satisfies IReadOnlyList<AlarmComment> for existing
|
||||
// consumers).
|
||||
// -------------------------------------------------------------------------
|
||||
/// <summary>Verifies that the Comments collection is an ImmutableList, enabling O(log n) append and satisfying IReadOnlyList consumers (Core.ScriptedAlarms-008).</summary>
|
||||
[Fact]
|
||||
public async Task Comments_collection_uses_ImmutableList_for_efficient_append(/* -008 */)
|
||||
{
|
||||
@@ -836,6 +863,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
// propagated, not silently discarded. The class-level remarks promise a
|
||||
// diagnostic log line for no-op disabled-alarm evaluations.
|
||||
// -------------------------------------------------------------------------
|
||||
/// <summary>Verifies that TransitionResult.NoOp preserves the supplied diagnostic reason string for caller logging (Core.ScriptedAlarms-011).</summary>
|
||||
[Fact]
|
||||
public void TransitionResult_NoOp_propagates_reason(/* -011 */)
|
||||
{
|
||||
@@ -845,6 +873,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
"NoOp reason must be preserved on the TransitionResult so callers can log it");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that TransitionResult.None carries a null reason, distinguishing it from the NoOp factory (Core.ScriptedAlarms-011).</summary>
|
||||
[Fact]
|
||||
public void TransitionResult_None_carries_no_reason(/* -011 */)
|
||||
{
|
||||
@@ -875,20 +904,32 @@ public sealed class ScriptedAlarmEngineTests
|
||||
private sealed class FailOnSaveAlarmStateStore : IAlarmStateStore
|
||||
{
|
||||
private readonly InMemoryAlarmStateStore _inner = new();
|
||||
/// <summary>Gets or sets a value indicating whether the next SaveAsync call should throw a simulated failure.</summary>
|
||||
public bool FailSave { get; set; }
|
||||
|
||||
/// <summary>Loads an alarm condition state by ID from the inner store.</summary>
|
||||
/// <param name="alarmId">The ID of the alarm condition state to load.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
|
||||
=> _inner.LoadAsync(alarmId, ct);
|
||||
|
||||
/// <summary>Loads all alarm condition states from the inner store.</summary>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
|
||||
=> _inner.LoadAllAsync(ct);
|
||||
|
||||
/// <summary>Saves an alarm condition state, optionally throwing if FailSave is set.</summary>
|
||||
/// <param name="state">The alarm condition state to save.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
public Task SaveAsync(AlarmConditionState state, CancellationToken ct)
|
||||
{
|
||||
if (FailSave) throw new InvalidOperationException("Simulated store failure");
|
||||
return _inner.SaveAsync(state, ct);
|
||||
}
|
||||
|
||||
/// <summary>Removes an alarm condition state by ID from the inner store.</summary>
|
||||
/// <param name="alarmId">The ID of the alarm condition state to remove.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
public Task RemoveAsync(string alarmId, CancellationToken ct)
|
||||
=> _inner.RemoveAsync(alarmId, ct);
|
||||
}
|
||||
@@ -900,15 +941,25 @@ public sealed class ScriptedAlarmEngineTests
|
||||
private sealed class BlockingSaveAlarmStateStore : IAlarmStateStore
|
||||
{
|
||||
private readonly InMemoryAlarmStateStore _inner = new();
|
||||
/// <summary>Gets or sets a TaskCompletionSource that, when set, blocks the next SaveAsync until signalled.</summary>
|
||||
public TaskCompletionSource? BlockNextSave { get; set; }
|
||||
/// <summary>Gets a value indicating whether a SaveAsync call is currently blocked waiting on BlockNextSave.</summary>
|
||||
public bool SaveInProgress { get; private set; }
|
||||
|
||||
/// <summary>Loads an alarm condition state by ID from the inner store.</summary>
|
||||
/// <param name="alarmId">The ID of the alarm condition state to load.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
|
||||
=> _inner.LoadAsync(alarmId, ct);
|
||||
|
||||
/// <summary>Loads all alarm condition states from the inner store.</summary>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
|
||||
=> _inner.LoadAllAsync(ct);
|
||||
|
||||
/// <summary>Saves an alarm condition state, optionally blocking on BlockNextSave gate.</summary>
|
||||
/// <param name="state">The alarm condition state to save.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
|
||||
{
|
||||
var gate = BlockNextSave;
|
||||
@@ -922,12 +973,16 @@ public sealed class ScriptedAlarmEngineTests
|
||||
await _inner.SaveAsync(state, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Removes an alarm condition state by ID from the inner store.</summary>
|
||||
/// <param name="alarmId">The ID of the alarm condition state to remove.</param>
|
||||
/// <param name="ct">A cancellation token.</param>
|
||||
public Task RemoveAsync(string alarmId, CancellationToken ct)
|
||||
=> _inner.RemoveAsync(alarmId, ct);
|
||||
}
|
||||
|
||||
// --- Core.ScriptedAlarms-009: per-alarm evaluation-scratch reuse ---
|
||||
|
||||
/// <summary>Verifies that re-evaluations reuse the same read cache dictionary instance instead of allocating a new one.</summary>
|
||||
[Fact]
|
||||
public async Task Reevaluation_reuses_the_same_read_cache_dictionary()
|
||||
{
|
||||
@@ -960,6 +1015,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
scratchAfterPush!["Temp"].Value.ShouldBe(150, "refill must update the existing dictionary in place");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that re-evaluations reuse the same predicate context instance across evaluations.</summary>
|
||||
[Fact]
|
||||
public async Task Reevaluation_reuses_the_same_predicate_context()
|
||||
{
|
||||
@@ -985,6 +1041,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
"the AlarmPredicateContext must be reused across evaluations (Core.ScriptedAlarms-009).");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LoadAsync clears prior evaluation scratch so new alarms use fresh scratch.</summary>
|
||||
[Fact]
|
||||
public async Task LoadAsync_drops_the_prior_generations_scratch()
|
||||
{
|
||||
@@ -1018,6 +1075,7 @@ public sealed class ScriptedAlarmEngineTests
|
||||
|
||||
// --- Core.Scripting-016: engine routes compiles through CompiledScriptCache ---
|
||||
|
||||
/// <summary>Verifies that Dispose unloads the compiled predicate assembly via the script cache.</summary>
|
||||
[Fact]
|
||||
public void Dispose_unloads_compiled_predicate_assembly()
|
||||
{
|
||||
|
||||
@@ -40,6 +40,7 @@ public sealed class ScriptedAlarmSourceTests
|
||||
return (engine, source, up);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribing with an empty filter receives every alarm emission.</summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_with_empty_filter_receives_every_alarm_emission()
|
||||
{
|
||||
@@ -64,6 +65,7 @@ public sealed class ScriptedAlarmSourceTests
|
||||
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribing with an equipment prefix filters alarms by that prefix.</summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_with_equipment_prefix_filters_by_that_prefix()
|
||||
{
|
||||
@@ -86,6 +88,7 @@ public sealed class ScriptedAlarmSourceTests
|
||||
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing stops further alarm events.</summary>
|
||||
[Fact]
|
||||
public async Task Unsubscribe_stops_further_events()
|
||||
{
|
||||
@@ -104,6 +107,7 @@ public sealed class ScriptedAlarmSourceTests
|
||||
events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AcknowledgeAsync routes to the engine with a default user.</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
||||
{
|
||||
@@ -125,6 +129,7 @@ public sealed class ScriptedAlarmSourceTests
|
||||
state.LastAckComment.ShouldBe("ack via opcua");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null arguments are rejected.</summary>
|
||||
[Fact]
|
||||
public async Task Null_arguments_rejected()
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class CompiledScriptCacheTests
|
||||
public int Count;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the first call to GetOrCompile compiles the script and caches the evaluator.</summary>
|
||||
[Fact]
|
||||
public void First_call_compiles_and_caches()
|
||||
{
|
||||
@@ -30,6 +31,7 @@ public sealed class CompiledScriptCacheTests
|
||||
cache.Contains("""return 42;""").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that identical source code returns the same cached evaluator instance.</summary>
|
||||
[Fact]
|
||||
public void Identical_source_returns_the_same_compiled_evaluator()
|
||||
{
|
||||
@@ -40,6 +42,7 @@ public sealed class CompiledScriptCacheTests
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that different source code produces different evaluator instances.</summary>
|
||||
[Fact]
|
||||
public void Different_source_produces_different_evaluator()
|
||||
{
|
||||
@@ -50,6 +53,7 @@ public sealed class CompiledScriptCacheTests
|
||||
cache.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that whitespace differences in source code cause cache misses.</summary>
|
||||
[Fact]
|
||||
public void Whitespace_difference_misses_cache()
|
||||
{
|
||||
@@ -61,6 +65,7 @@ public sealed class CompiledScriptCacheTests
|
||||
cache.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cached evaluator produces correct results when executed.</summary>
|
||||
[Fact]
|
||||
public async Task Cached_evaluator_still_runs_correctly()
|
||||
{
|
||||
@@ -76,6 +81,7 @@ public sealed class CompiledScriptCacheTests
|
||||
second.ShouldBe(21.0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that failed compiles are evicted so that retried source with corrections can succeed.</summary>
|
||||
[Fact]
|
||||
public void Failed_compile_is_evicted_so_retry_with_corrected_source_works()
|
||||
{
|
||||
@@ -90,6 +96,7 @@ public sealed class CompiledScriptCacheTests
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Clear removes all cached entries.</summary>
|
||||
[Fact]
|
||||
public void Clear_drops_every_entry()
|
||||
{
|
||||
@@ -103,6 +110,7 @@ public sealed class CompiledScriptCacheTests
|
||||
cache.Contains("""return 1;""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent compiles of the same source deduplicate to a single compilation.</summary>
|
||||
[Fact]
|
||||
public void Concurrent_compiles_of_the_same_source_deduplicate()
|
||||
{
|
||||
@@ -124,6 +132,7 @@ public sealed class CompiledScriptCacheTests
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that different TContext and TResult type parameter pairs use separate cache instances.</summary>
|
||||
[Fact]
|
||||
public void Different_TContext_TResult_pairs_use_separate_cache_instances()
|
||||
{
|
||||
@@ -142,6 +151,7 @@ public sealed class CompiledScriptCacheTests
|
||||
boolCache.Contains("""return 1;""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null source throws ArgumentNullException.</summary>
|
||||
[Fact]
|
||||
public void Null_source_throws_ArgumentNullException()
|
||||
{
|
||||
@@ -149,6 +159,7 @@ public sealed class CompiledScriptCacheTests
|
||||
Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that failed compile eviction does not remove a concurrent retry entry (Core.Scripting-006).</summary>
|
||||
[Fact]
|
||||
public void Failed_compile_eviction_does_not_remove_a_concurrent_retry_entry()
|
||||
{
|
||||
@@ -211,6 +222,7 @@ public sealed class CompiledScriptCacheTests
|
||||
"the entry under the key must still be the fresh Lazy — an unconditional TryRemove(key) would have evicted it");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the failed compile path still evicts its own faulted entry.</summary>
|
||||
[Fact]
|
||||
public void Failed_compile_path_still_evicts_its_own_faulted_entry()
|
||||
{
|
||||
@@ -222,6 +234,7 @@ public sealed class CompiledScriptCacheTests
|
||||
cache.Count.ShouldBe(0, "faulted Lazy must still be evicted after compile failure");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Clear uses value-scoped TryRemove so a race-inserted entry survives (Core.Scripting-014).</summary>
|
||||
[Fact]
|
||||
public void Clear_uses_value_scoped_TryRemove_so_a_race_inserted_entry_survives()
|
||||
{
|
||||
@@ -290,6 +303,7 @@ public sealed class CompiledScriptCacheTests
|
||||
|
||||
// --- Core.Scripting-008: collectible AssemblyLoadContext unload ---
|
||||
|
||||
/// <summary>Verifies that Dispose unloads the compiled script assembly load context (Core.Scripting-008).</summary>
|
||||
[Fact]
|
||||
public void Dispose_unloads_compiled_script_assembly_load_context()
|
||||
{
|
||||
@@ -342,6 +356,7 @@ public sealed class CompiledScriptCacheTests
|
||||
return del.Method.Module.Assembly;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Clear disposes every materialised evaluator (Core.Scripting-008).</summary>
|
||||
[Fact]
|
||||
public void Clear_disposes_every_materialised_evaluator()
|
||||
{
|
||||
@@ -382,6 +397,7 @@ public sealed class CompiledScriptCacheTests
|
||||
return weaks;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetOrCompile after Dispose throws ObjectDisposedException.</summary>
|
||||
[Fact]
|
||||
public void GetOrCompile_after_Dispose_throws_ObjectDisposedException()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DependencyExtractorTests
|
||||
{
|
||||
/// <summary>Verifies that a single literal tag read is extracted.</summary>
|
||||
[Fact]
|
||||
public void Extracts_single_literal_read()
|
||||
{
|
||||
@@ -24,6 +25,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Rejections.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple distinct tag reads are extracted.</summary>
|
||||
[Fact]
|
||||
public void Extracts_multiple_distinct_reads()
|
||||
{
|
||||
@@ -39,6 +41,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Reads.ShouldContain("Line1/B");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that identical reads are deduplicated.</summary>
|
||||
[Fact]
|
||||
public void Deduplicates_identical_reads_across_the_script()
|
||||
{
|
||||
@@ -53,6 +56,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Reads.ShouldContain("X");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that virtual tag writes are tracked separately from reads.</summary>
|
||||
[Fact]
|
||||
public void Tracks_virtual_tag_writes_separately_from_reads()
|
||||
{
|
||||
@@ -69,6 +73,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Writes.ShouldNotContain("InTag");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that variable paths are rejected.</summary>
|
||||
[Fact]
|
||||
public void Rejects_variable_path()
|
||||
{
|
||||
@@ -82,6 +87,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concatenated string paths are rejected.</summary>
|
||||
[Fact]
|
||||
public void Rejects_concatenated_path()
|
||||
{
|
||||
@@ -91,6 +97,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that interpolated string paths are rejected.</summary>
|
||||
[Fact]
|
||||
public void Rejects_interpolated_path()
|
||||
{
|
||||
@@ -103,6 +110,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that method-returned paths are rejected.</summary>
|
||||
[Fact]
|
||||
public void Rejects_method_returned_path()
|
||||
{
|
||||
@@ -115,6 +123,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty literal paths are rejected.</summary>
|
||||
[Fact]
|
||||
public void Rejects_empty_literal_path()
|
||||
{
|
||||
@@ -124,6 +133,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Rejections[0].Message.ShouldContain("empty");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that whitespace-only paths are rejected.</summary>
|
||||
[Fact]
|
||||
public void Rejects_whitespace_only_path()
|
||||
{
|
||||
@@ -132,6 +142,7 @@ public sealed class DependencyExtractorTests
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that free-function GetTag calls are ignored.</summary>
|
||||
[Fact]
|
||||
public void Ignores_non_ctx_method_named_GetTag_free_function()
|
||||
{
|
||||
@@ -147,6 +158,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Reads.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that member-access GetTag on non-ctx receivers is ignored.</summary>
|
||||
[Fact]
|
||||
public void Ignores_member_access_GetTag_on_non_ctx_receiver()
|
||||
{
|
||||
@@ -165,6 +177,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Reads.ShouldNotContain("X");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty source is handled without error.</summary>
|
||||
[Fact]
|
||||
public void Empty_source_is_a_no_op()
|
||||
{
|
||||
@@ -173,6 +186,7 @@ public sealed class DependencyExtractorTests
|
||||
DependencyExtractor.Extract(null!).IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rejections include source span for UI pointing.</summary>
|
||||
[Fact]
|
||||
public void Rejection_carries_source_span_for_UI_pointing()
|
||||
{
|
||||
@@ -185,6 +199,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Rejections[0].Span.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that all bad paths are reported in a single pass.</summary>
|
||||
[Fact]
|
||||
public void Multiple_bad_paths_all_reported_in_one_pass()
|
||||
{
|
||||
@@ -197,6 +212,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Rejections.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nested literal GetTag calls inside expressions are extracted.</summary>
|
||||
[Fact]
|
||||
public void Nested_literal_GetTag_inside_expression_is_extracted()
|
||||
{
|
||||
@@ -210,6 +226,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Reads.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that single-line raw string literal paths are accepted.</summary>
|
||||
[Fact]
|
||||
public void Accepts_single_line_raw_string_literal_path()
|
||||
{
|
||||
@@ -224,6 +241,7 @@ public sealed class DependencyExtractorTests
|
||||
result.Rejections.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multi-line raw string literal paths are accepted.</summary>
|
||||
[Fact]
|
||||
public void Accepts_multi_line_raw_string_literal_path()
|
||||
{
|
||||
|
||||
@@ -13,12 +13,19 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
/// </summary>
|
||||
public sealed class FakeScriptContext : ScriptContext
|
||||
{
|
||||
/// <summary>Gets the dictionary of tags available in this context.</summary>
|
||||
public Dictionary<string, DataValueSnapshot> Tags { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Gets the log of virtual tag write operations.</summary>
|
||||
public List<(string Path, object? Value)> Writes { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DateTime Now { get; } = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ILogger Logger { get; } = new LoggerConfiguration().CreateLogger();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DataValueSnapshot GetTag(string path)
|
||||
{
|
||||
return Tags.TryGetValue(path, out var v)
|
||||
@@ -26,11 +33,18 @@ public sealed class FakeScriptContext : ScriptContext
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, Now); // BadNodeIdUnknown
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetVirtualTag(string path, object? value)
|
||||
{
|
||||
Writes.Add((path, value));
|
||||
}
|
||||
|
||||
/// <summary>Seeds the context with a tag value for testing.</summary>
|
||||
/// <param name="path">The tag path.</param>
|
||||
/// <param name="value">The tag value.</param>
|
||||
/// <param name="statusCode">The OPC UA status code (default: 0).</param>
|
||||
/// <param name="sourceTs">The source timestamp (default: <see cref="Now"/>).</param>
|
||||
/// <returns>This instance for method chaining.</returns>
|
||||
public FakeScriptContext Seed(string path, object? value,
|
||||
uint statusCode = 0u, DateTime? sourceTs = null)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptContextTests
|
||||
{
|
||||
/// <summary>Verifies that Deadband returns false when the difference exactly equals the tolerance.</summary>
|
||||
[Fact]
|
||||
public void Deadband_returns_false_when_difference_equals_tolerance()
|
||||
{
|
||||
@@ -21,6 +22,7 @@ public sealed class ScriptContextTests
|
||||
ScriptContext.Deadband(current: 10.5, previous: 10.0, tolerance: 0.5).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Deadband returns true when the difference exceeds the tolerance.</summary>
|
||||
[Fact]
|
||||
public void Deadband_returns_true_when_difference_just_exceeds_tolerance()
|
||||
{
|
||||
@@ -28,12 +30,14 @@ public sealed class ScriptContextTests
|
||||
ScriptContext.Deadband(current: 10.6, previous: 10.0, tolerance: 0.5).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Deadband returns false when the difference is below the tolerance.</summary>
|
||||
[Fact]
|
||||
public void Deadband_returns_false_when_difference_just_below_tolerance()
|
||||
{
|
||||
ScriptContext.Deadband(current: 10.4, previous: 10.0, tolerance: 0.5).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Deadband returns the same result regardless of the direction of change.</summary>
|
||||
[Fact]
|
||||
public void Deadband_is_symmetric_in_direction_of_change()
|
||||
{
|
||||
@@ -42,12 +46,14 @@ public sealed class ScriptContextTests
|
||||
ScriptContext.Deadband(current: 11.0, previous: 10.0, tolerance: 0.5).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Deadband returns false when current and previous values are equal.</summary>
|
||||
[Fact]
|
||||
public void Deadband_returns_false_when_values_are_equal()
|
||||
{
|
||||
ScriptContext.Deadband(current: 10.0, previous: 10.0, tolerance: 0.001).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Deadband with zero tolerance trips on any non-zero difference.</summary>
|
||||
[Fact]
|
||||
public void Deadband_with_zero_tolerance_returns_true_for_any_difference()
|
||||
{
|
||||
@@ -57,6 +63,7 @@ public sealed class ScriptContextTests
|
||||
ScriptContext.Deadband(current: 10.0, previous: 10.000001, tolerance: 0).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Deadband with negative tolerance trips on any non-zero difference.</summary>
|
||||
[Fact]
|
||||
public void Deadband_with_negative_tolerance_always_trips_for_unequal_values()
|
||||
{
|
||||
|
||||
@@ -18,7 +18,10 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
{
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
/// <summary>Gets the list of captured log events.</summary>
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
/// <summary>Emits a log event by adding it to the events list.</summary>
|
||||
/// <param name="logEvent">The log event to emit.</param>
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
|
||||
@@ -40,6 +43,7 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
return (scriptLogger, scriptSink, mainSink);
|
||||
}
|
||||
|
||||
/// <summary>Tests that Info event lands in scripts sink but not in main.</summary>
|
||||
[Fact]
|
||||
public void Info_event_lands_in_scripts_sink_but_not_in_main()
|
||||
{
|
||||
@@ -50,6 +54,7 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
mainSink.Events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Tests that Warning event lands in scripts sink but not in main.</summary>
|
||||
[Fact]
|
||||
public void Warning_event_lands_in_scripts_sink_but_not_in_main()
|
||||
{
|
||||
@@ -60,6 +65,7 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
mainSink.Events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Tests that Error event is mirrored to main at Warning level.</summary>
|
||||
[Fact]
|
||||
public void Error_event_mirrored_to_main_at_Warning_level()
|
||||
{
|
||||
@@ -72,6 +78,7 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning, "Error+ is downgraded to Warning in the main log");
|
||||
}
|
||||
|
||||
/// <summary>Tests that mirrored event includes ScriptName and original level.</summary>
|
||||
[Fact]
|
||||
public void Mirrored_event_includes_ScriptName_and_original_level()
|
||||
{
|
||||
@@ -86,6 +93,7 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
((ScalarValue)forwarded.Properties["OriginalLevel"]).Value.ShouldBe(LogEventLevel.Error);
|
||||
}
|
||||
|
||||
/// <summary>Tests that mirrored event preserves exception for main log stack trace.</summary>
|
||||
[Fact]
|
||||
public void Mirrored_event_preserves_exception_for_main_log_stack_trace()
|
||||
{
|
||||
@@ -97,6 +105,7 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
mainSink.Events[0].Exception.ShouldBeSameAs(ex);
|
||||
}
|
||||
|
||||
/// <summary>Tests that Fatal event is mirrored just like Error.</summary>
|
||||
[Fact]
|
||||
public void Fatal_event_mirrored_just_like_Error()
|
||||
{
|
||||
@@ -106,6 +115,7 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning);
|
||||
}
|
||||
|
||||
/// <summary>Tests that missing ScriptName property falls back to unknown.</summary>
|
||||
[Fact]
|
||||
public void Missing_ScriptName_property_falls_back_to_unknown()
|
||||
{
|
||||
@@ -126,12 +136,14 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
Should.NotThrow(() => companion.Emit(ev));
|
||||
}
|
||||
|
||||
/// <summary>Tests that null main logger is rejected.</summary>
|
||||
[Fact]
|
||||
public void Null_main_logger_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptLogCompanionSink(null!));
|
||||
}
|
||||
|
||||
/// <summary>Tests that custom mirror threshold is applied.</summary>
|
||||
[Fact]
|
||||
public void Custom_mirror_threshold_applied()
|
||||
{
|
||||
@@ -153,6 +165,7 @@ public sealed class ScriptLogCompanionSinkTests
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Tests that factory plus companion sink integration surfaces script error in both logs.</summary>
|
||||
[Fact]
|
||||
public void Factory_plus_companion_sink_integration_surfaces_script_error_in_both_logs()
|
||||
{
|
||||
|
||||
@@ -18,10 +18,15 @@ public sealed class ScriptLoggerFactoryTests
|
||||
/// <summary>Capturing sink that collects every emitted LogEvent for assertion.</summary>
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
/// <summary>Gets the list of captured log events.</summary>
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
|
||||
/// <summary>Adds a log event to the captured list.</summary>
|
||||
/// <param name="logEvent">The log event to capture.</param>
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Create sets the ScriptName structured property.</summary>
|
||||
[Fact]
|
||||
public void Create_sets_ScriptName_structured_property()
|
||||
{
|
||||
@@ -38,6 +43,7 @@ public sealed class ScriptLoggerFactoryTests
|
||||
((ScalarValue)ev.Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("LineRate");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that each script gets its own property value.</summary>
|
||||
[Fact]
|
||||
public void Each_script_gets_its_own_property_value()
|
||||
{
|
||||
@@ -55,6 +61,7 @@ public sealed class ScriptLoggerFactoryTests
|
||||
((ScalarValue)sink.Events[2].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that error-level events preserve level and exception.</summary>
|
||||
[Fact]
|
||||
public void Error_level_event_preserves_level_and_exception()
|
||||
{
|
||||
@@ -68,12 +75,14 @@ public sealed class ScriptLoggerFactoryTests
|
||||
sink.Events[0].Exception.ShouldBeOfType<InvalidOperationException>();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null root logger is rejected.</summary>
|
||||
[Fact]
|
||||
public void Null_root_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptLoggerFactory(null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty script names are rejected.</summary>
|
||||
[Fact]
|
||||
public void Empty_script_name_rejected()
|
||||
{
|
||||
@@ -84,6 +93,7 @@ public sealed class ScriptLoggerFactoryTests
|
||||
Should.Throw<ArgumentException>(() => factory.Create(null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the ScriptNameProperty constant is stable.</summary>
|
||||
[Fact]
|
||||
public void ScriptNameProperty_constant_is_stable()
|
||||
{
|
||||
|
||||
@@ -14,12 +14,14 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptSandboxBuildTests
|
||||
{
|
||||
/// <summary>Verifies that a null context type throws ArgumentNullException.</summary>
|
||||
[Fact]
|
||||
public void Null_context_type_throws_ArgumentNullException()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => ScriptSandbox.Build(null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a non-ScriptContext type throws ArgumentException.</summary>
|
||||
[Fact]
|
||||
public void Non_ScriptContext_type_throws_ArgumentException()
|
||||
{
|
||||
@@ -30,6 +32,7 @@ public sealed class ScriptSandboxBuildTests
|
||||
.ParamName.ShouldBe("contextType");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the abstract ScriptContext base type is accepted by the sandbox builder.</summary>
|
||||
[Fact]
|
||||
public void Abstract_ScriptContext_base_type_is_accepted()
|
||||
{
|
||||
@@ -40,6 +43,7 @@ public sealed class ScriptSandboxBuildTests
|
||||
options.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a concrete subclass is accepted and its assembly is referenced in the sandbox.</summary>
|
||||
[Fact]
|
||||
public void Concrete_subclass_is_accepted_and_its_assembly_referenced()
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptSandboxTests
|
||||
{
|
||||
/// <summary>Verifies that a baseline script compiles successfully.</summary>
|
||||
[Fact]
|
||||
public void Happy_path_script_compiles_and_returns()
|
||||
{
|
||||
@@ -25,6 +26,7 @@ public sealed class ScriptSandboxTests
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a script can compile, run, and read a seeded tag.</summary>
|
||||
[Fact]
|
||||
public async Task Happy_path_script_runs_and_reads_seeded_tag()
|
||||
{
|
||||
@@ -36,6 +38,7 @@ public sealed class ScriptSandboxTests
|
||||
result.ShouldBe(42.0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SetVirtualTag records write operations.</summary>
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_records_the_write()
|
||||
{
|
||||
@@ -51,6 +54,7 @@ public sealed class ScriptSandboxTests
|
||||
ctx.Writes[0].Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that file I/O is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_File_IO_at_compile()
|
||||
{
|
||||
@@ -59,6 +63,7 @@ public sealed class ScriptSandboxTests
|
||||
"""return System.IO.File.ReadAllText("c:/secrets.txt");"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HttpClient is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_HttpClient_at_compile()
|
||||
{
|
||||
@@ -70,6 +75,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Process.Start is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_Process_Start_at_compile()
|
||||
{
|
||||
@@ -81,6 +87,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Assembly.Load is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_Reflection_Assembly_Load_at_compile()
|
||||
{
|
||||
@@ -92,6 +99,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Environment.Exit is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_Environment_Exit_at_compile()
|
||||
{
|
||||
@@ -107,6 +115,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Environment.FailFast is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_Environment_FailFast_at_compile()
|
||||
{
|
||||
@@ -120,6 +129,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AppDomain is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_AppDomain_at_compile()
|
||||
{
|
||||
@@ -133,6 +143,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GC.Collect is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_GC_Collect_at_compile()
|
||||
{
|
||||
@@ -146,6 +157,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Activator.CreateInstance is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_Activator_CreateInstance_at_compile()
|
||||
{
|
||||
@@ -159,6 +171,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Environment.GetEnvironmentVariable is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_Environment_GetEnvironmentVariable_at_compile()
|
||||
{
|
||||
@@ -178,6 +191,7 @@ public sealed class ScriptSandboxTests
|
||||
// name a forbidden type without producing any of those nodes. The broadened walker
|
||||
// resolves GetTypeInfo on every TypeSyntax / ExpressionSyntax so they are all caught.
|
||||
|
||||
/// <summary>Verifies that typeof a forbidden type is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_typeof_forbidden_type_at_compile()
|
||||
{
|
||||
@@ -188,6 +202,7 @@ public sealed class ScriptSandboxTests
|
||||
"""return typeof(System.IO.File).Name;"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a forbidden type in a generic argument is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_generic_type_argument_forbidden_type_at_compile()
|
||||
{
|
||||
@@ -201,6 +216,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a cast to a forbidden type is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_cast_to_forbidden_type_at_compile()
|
||||
{
|
||||
@@ -214,6 +230,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that default of a forbidden type is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_default_of_forbidden_type_at_compile()
|
||||
{
|
||||
@@ -227,6 +244,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an is-pattern with a forbidden type is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_is_pattern_forbidden_type_at_compile()
|
||||
{
|
||||
@@ -240,6 +258,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an as-expression with a forbidden type is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_as_expression_forbidden_type_at_compile()
|
||||
{
|
||||
@@ -253,6 +272,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that array creation with a forbidden element type is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_array_creation_forbidden_element_type_at_compile()
|
||||
{
|
||||
@@ -266,6 +286,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a local variable with a forbidden type is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_local_declared_variable_forbidden_type_at_compile()
|
||||
{
|
||||
@@ -279,6 +300,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that typeof a forbidden type inside Activator is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_typeof_forbidden_type_inside_Activator_at_compile()
|
||||
{
|
||||
@@ -293,6 +315,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an allowed generic type argument still compiles.</summary>
|
||||
[Fact]
|
||||
public async Task Allowed_generic_type_argument_still_compiles()
|
||||
{
|
||||
@@ -307,6 +330,7 @@ public sealed class ScriptSandboxTests
|
||||
result.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that typeof an allowed type still compiles.</summary>
|
||||
[Fact]
|
||||
public async Task Allowed_typeof_still_compiles()
|
||||
{
|
||||
@@ -317,6 +341,7 @@ public sealed class ScriptSandboxTests
|
||||
result.ShouldBe("Int32");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that script exceptions propagate unwrapped.</summary>
|
||||
[Fact]
|
||||
public async Task Script_exception_propagates_unwrapped()
|
||||
{
|
||||
@@ -326,6 +351,7 @@ public sealed class ScriptSandboxTests
|
||||
await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ctx.Now is available without reaching the wall clock.</summary>
|
||||
[Fact]
|
||||
public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock()
|
||||
{
|
||||
@@ -334,6 +360,7 @@ public sealed class ScriptSandboxTests
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Deadband helper is reachable from scripts.</summary>
|
||||
[Fact]
|
||||
public void Deadband_helper_is_reachable_from_scripts()
|
||||
{
|
||||
@@ -342,6 +369,7 @@ public sealed class ScriptSandboxTests
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LINQ Enumerable is available from scripts.</summary>
|
||||
[Fact]
|
||||
public async Task Linq_Enumerable_is_available_from_scripts()
|
||||
{
|
||||
@@ -356,6 +384,7 @@ public sealed class ScriptSandboxTests
|
||||
result.ShouldBe(12);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DataValueSnapshot is usable in scripts.</summary>
|
||||
[Fact]
|
||||
public async Task DataValueSnapshot_is_usable_in_scripts()
|
||||
{
|
||||
@@ -370,6 +399,7 @@ public sealed class ScriptSandboxTests
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that compile errors include location information in diagnostics.</summary>
|
||||
[Fact]
|
||||
public void Compile_error_gives_location_in_diagnostics()
|
||||
{
|
||||
@@ -392,6 +422,7 @@ public sealed class ScriptSandboxTests
|
||||
// rejection. Adding them here closes the coverage gap that allowed Core.Scripting-001 and
|
||||
// -002 to go undetected.
|
||||
|
||||
/// <summary>Verifies that Thread instantiation is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_Thread_new_at_compile()
|
||||
{
|
||||
@@ -407,6 +438,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Task.Run is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_Tasks_TaskRun_at_compile()
|
||||
{
|
||||
@@ -421,6 +453,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that InteropServices is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_InteropServices_at_compile()
|
||||
{
|
||||
@@ -434,6 +467,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Win32 Registry access is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_Win32_Registry_at_compile()
|
||||
{
|
||||
@@ -451,6 +485,7 @@ public sealed class ScriptSandboxTests
|
||||
// the original deny-list missed. Each is denied type-granularly in
|
||||
// ForbiddenTypeAnalyzer.ForbiddenFullTypeNames; these tests pin the rejection.
|
||||
|
||||
/// <summary>Verifies that ThreadPool.QueueUserWorkItem is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_ThreadPool_QueueUserWorkItem_at_compile()
|
||||
{
|
||||
@@ -466,6 +501,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Timer instantiation is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_Timer_new_at_compile()
|
||||
{
|
||||
@@ -480,6 +516,7 @@ public sealed class ScriptSandboxTests
|
||||
"""));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AssemblyLoadContext is rejected at compile time.</summary>
|
||||
[Fact]
|
||||
public void Rejects_AssemblyLoadContext_at_compile()
|
||||
{
|
||||
@@ -497,6 +534,7 @@ public sealed class ScriptSandboxTests
|
||||
|
||||
// --- Core.Scripting-013: wrapper-source injection ---
|
||||
|
||||
/// <summary>Verifies that sibling method injection via balanced braces is rejected.</summary>
|
||||
[Fact]
|
||||
public void Rejects_sibling_method_injection_via_balanced_braces()
|
||||
{
|
||||
@@ -513,6 +551,7 @@ public sealed class ScriptSandboxTests
|
||||
ex.Message.ShouldContain("Core.Scripting-013");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sibling class injection via balanced braces is rejected.</summary>
|
||||
[Fact]
|
||||
public void Rejects_sibling_class_injection_via_balanced_braces()
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TimedScriptEvaluatorTests
|
||||
{
|
||||
/// <summary>Verifies that fast scripts complete under timeout and return value.</summary>
|
||||
[Fact]
|
||||
public async Task Fast_script_completes_under_timeout_and_returns_value()
|
||||
{
|
||||
@@ -26,6 +27,7 @@ public sealed class TimedScriptEvaluatorTests
|
||||
result.ShouldBe(42.0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that scripts longer than timeout throw ScriptTimeoutException.</summary>
|
||||
[Fact]
|
||||
public async Task Script_longer_than_timeout_throws_ScriptTimeoutException()
|
||||
{
|
||||
@@ -47,6 +49,7 @@ public sealed class TimedScriptEvaluatorTests
|
||||
ex.Message.ShouldContain("50.0");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that caller cancellation takes precedence over timeout.</summary>
|
||||
[Fact]
|
||||
public async Task Caller_cancellation_takes_precedence_over_timeout()
|
||||
{
|
||||
@@ -67,6 +70,7 @@ public sealed class TimedScriptEvaluatorTests
|
||||
await timed.RunAsync(new FakeScriptContext(), cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that default timeout is 250ms per plan.</summary>
|
||||
[Fact]
|
||||
public void Default_timeout_is_250ms_per_plan()
|
||||
{
|
||||
@@ -74,6 +78,7 @@ public sealed class TimedScriptEvaluatorTests
|
||||
.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that zero or negative timeout is rejected at construction.</summary>
|
||||
[Fact]
|
||||
public void Zero_or_negative_timeout_is_rejected_at_construction()
|
||||
{
|
||||
@@ -84,6 +89,7 @@ public sealed class TimedScriptEvaluatorTests
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromMilliseconds(-1)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null inner evaluator is rejected.</summary>
|
||||
[Fact]
|
||||
public void Null_inner_is_rejected()
|
||||
{
|
||||
@@ -91,6 +97,7 @@ public sealed class TimedScriptEvaluatorTests
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(null!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null context is rejected.</summary>
|
||||
[Fact]
|
||||
public void Null_context_is_rejected()
|
||||
{
|
||||
@@ -100,6 +107,7 @@ public sealed class TimedScriptEvaluatorTests
|
||||
await timed.RunAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that script exceptions propagate unwrapped.</summary>
|
||||
[Fact]
|
||||
public async Task Script_exception_propagates_unwrapped()
|
||||
{
|
||||
@@ -115,6 +123,7 @@ public sealed class TimedScriptEvaluatorTests
|
||||
ex.Message.ShouldBe("script boom");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ScriptTimeoutException message points at diagnostic path.</summary>
|
||||
[Fact]
|
||||
public async Task ScriptTimeoutException_message_points_at_diagnostic_path()
|
||||
{
|
||||
@@ -133,6 +142,7 @@ public sealed class TimedScriptEvaluatorTests
|
||||
ex.Message.ShouldContain("widening the timeout");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that caller cancellation wins even when timeout fires first.</summary>
|
||||
[Fact]
|
||||
public async Task Caller_cancellation_wins_even_when_timeout_fires_first()
|
||||
{
|
||||
|
||||
@@ -47,6 +47,7 @@ public sealed class PermissionTrieBuilderTests
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
/// <summary>Verifies that Build with ScopePaths places UnsLine row at correct multi-level node.</summary>
|
||||
[Fact]
|
||||
public void Build_With_ScopePaths_Places_UnsLine_Row_At_Correct_Multi_Level_Node()
|
||||
{
|
||||
@@ -73,6 +74,7 @@ public sealed class PermissionTrieBuilderTests
|
||||
"grant anchored at line-42 must not leak to sibling line-99 under the same area");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Build without ScopePaths falls back to root child for deterministic tests.</summary>
|
||||
[Fact]
|
||||
public void Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests()
|
||||
{
|
||||
@@ -117,6 +119,7 @@ public sealed class PermissionTrieBuilderTests
|
||||
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopePath);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no diagnostic is emitted when all sub-cluster rows have ScopePaths.</summary>
|
||||
[Fact]
|
||||
public void Build_No_Diagnostic_When_All_Sub_Cluster_Rows_Have_ScopePaths()
|
||||
{
|
||||
@@ -138,6 +141,7 @@ public sealed class PermissionTrieBuilderTests
|
||||
diagnostics.ShouldBeEmpty("no rows are missing a scope-path entry");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that diagnostic callback is optional when ScopePaths is null.</summary>
|
||||
[Fact]
|
||||
public void Build_Diagnostic_Callback_Optional_When_ScopePaths_Null()
|
||||
{
|
||||
|
||||
@@ -13,12 +13,14 @@ public sealed class PermissionTrieCacheTests
|
||||
GenerationId = generation,
|
||||
};
|
||||
|
||||
/// <summary>Verifies that GetTrie returns null when the cache is empty.</summary>
|
||||
[Fact]
|
||||
public void GetTrie_Empty_ReturnsNull()
|
||||
{
|
||||
new PermissionTrieCache().GetTrie("c1").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a trie installed can be retrieved with matching generation id.</summary>
|
||||
[Fact]
|
||||
public void Install_ThenGet_RoundTrips()
|
||||
{
|
||||
@@ -29,6 +31,7 @@ public sealed class PermissionTrieCacheTests
|
||||
cache.CurrentGenerationId("c1").ShouldBe(5);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that installing a new generation makes it the current generation.</summary>
|
||||
[Fact]
|
||||
public void NewGeneration_BecomesCurrent()
|
||||
{
|
||||
@@ -41,6 +44,7 @@ public sealed class PermissionTrieCacheTests
|
||||
cache.GetTrie("c1", 2).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that out-of-order installs do not downgrade the current generation.</summary>
|
||||
[Fact]
|
||||
public void OutOfOrder_Install_DoesNotDowngrade_Current()
|
||||
{
|
||||
@@ -52,6 +56,7 @@ public sealed class PermissionTrieCacheTests
|
||||
cache.GetTrie("c1", 1).ShouldNotBeNull("but older is still retrievable by explicit lookup");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Invalidate drops only the specified cluster.</summary>
|
||||
[Fact]
|
||||
public void Invalidate_DropsCluster()
|
||||
{
|
||||
@@ -65,6 +70,7 @@ public sealed class PermissionTrieCacheTests
|
||||
cache.GetTrie("c2").ShouldNotBeNull("sibling cluster unaffected");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Prune retains the most recent generations.</summary>
|
||||
[Fact]
|
||||
public void Prune_RetainsMostRecent()
|
||||
{
|
||||
@@ -79,6 +85,7 @@ public sealed class PermissionTrieCacheTests
|
||||
cache.GetTrie("c1", 1).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Prune with keepLatest greater than the cache size is a no-op.</summary>
|
||||
[Fact]
|
||||
public void Prune_LessThanKeep_IsNoOp()
|
||||
{
|
||||
@@ -91,6 +98,7 @@ public sealed class PermissionTrieCacheTests
|
||||
cache.CachedTrieCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that different clusters maintain independent generation tracking.</summary>
|
||||
[Fact]
|
||||
public void ClusterIsolation()
|
||||
{
|
||||
@@ -125,6 +133,7 @@ public sealed class PermissionTrieCacheTests
|
||||
cache.GetTrie("c1", 4).ShouldBeNull("generation 4 was pruned");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the current generation pointer is preserved after pruning.</summary>
|
||||
[Fact]
|
||||
public void Prune_Current_Pointer_Survives_Pruning()
|
||||
{
|
||||
|
||||
@@ -43,6 +43,7 @@ public sealed class PermissionTrieTests
|
||||
Kind = NodeHierarchyKind.SystemPlatform,
|
||||
};
|
||||
|
||||
/// <summary>Verifies cluster-level grant cascades to every tag.</summary>
|
||||
[Fact]
|
||||
public void ClusterLevelGrant_Cascades_ToEveryTag()
|
||||
{
|
||||
@@ -58,6 +59,7 @@ public sealed class PermissionTrieTests
|
||||
matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster);
|
||||
}
|
||||
|
||||
/// <summary>Verifies equipment-scope grant does not leak to sibling.</summary>
|
||||
[Fact]
|
||||
public void EquipmentScope_DoesNotLeak_ToSibling()
|
||||
{
|
||||
@@ -75,6 +77,7 @@ public sealed class PermissionTrieTests
|
||||
matchB.ShouldBeEmpty("grant at eq-A must not apply to sibling eq-B");
|
||||
}
|
||||
|
||||
/// <summary>Verifies multiple groups union their permission flags.</summary>
|
||||
[Fact]
|
||||
public void MultiGroup_Union_OrsPermissionFlags()
|
||||
{
|
||||
@@ -94,6 +97,7 @@ public sealed class PermissionTrieTests
|
||||
combined.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
|
||||
}
|
||||
|
||||
/// <summary>Verifies no matching group returns empty.</summary>
|
||||
[Fact]
|
||||
public void NoMatchingGroup_ReturnsEmpty()
|
||||
{
|
||||
@@ -107,6 +111,7 @@ public sealed class PermissionTrieTests
|
||||
matches.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies Galaxy folder segment grant does not leak to sibling folder.</summary>
|
||||
[Fact]
|
||||
public void Galaxy_FolderSegment_Grant_DoesNotLeak_To_Sibling_Folder()
|
||||
{
|
||||
@@ -147,6 +152,7 @@ public sealed class PermissionTrieTests
|
||||
"the trie walk reports the structural level where the grant was found — FolderSegment for Galaxy, not Equipment");
|
||||
}
|
||||
|
||||
/// <summary>Verifies Galaxy deep folder path all segments report folder segment scope.</summary>
|
||||
[Fact]
|
||||
public void Galaxy_DeepFolderPath_AllSegments_Report_FolderSegment_Scope()
|
||||
{
|
||||
@@ -173,6 +179,7 @@ public sealed class PermissionTrieTests
|
||||
"every matched folder level must report FolderSegment, never Equipment");
|
||||
}
|
||||
|
||||
/// <summary>Verifies cross-cluster grant does not leak.</summary>
|
||||
[Fact]
|
||||
public void CrossCluster_Grant_DoesNotLeak()
|
||||
{
|
||||
@@ -186,6 +193,7 @@ public sealed class PermissionTrieTests
|
||||
matches.ShouldBeEmpty("rows for cluster c-other must not land in c1's trie");
|
||||
}
|
||||
|
||||
/// <summary>Verifies build is idempotent.</summary>
|
||||
[Fact]
|
||||
public void Build_IsIdempotent()
|
||||
{
|
||||
|
||||
@@ -15,7 +15,10 @@ public sealed class TriePermissionEvaluatorTests
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
/// <summary>Gets or sets the current UTC time.</summary>
|
||||
public DateTime Utc { get; set; } = Now;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
@@ -62,6 +65,7 @@ public sealed class TriePermissionEvaluatorTests
|
||||
return new TriePermissionEvaluator(cache, _time);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authorization is allowed when required permission flags are matched.</summary>
|
||||
[Fact]
|
||||
public void Allow_When_RequiredFlag_Matched()
|
||||
{
|
||||
@@ -73,6 +77,7 @@ public sealed class TriePermissionEvaluatorTests
|
||||
decision.Provenance.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authorization is denied when no matching group is found.</summary>
|
||||
[Fact]
|
||||
public void NotGranted_When_NoMatchingGroup()
|
||||
{
|
||||
@@ -84,6 +89,7 @@ public sealed class TriePermissionEvaluatorTests
|
||||
decision.Provenance.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authorization is denied when permission flags are insufficient.</summary>
|
||||
[Fact]
|
||||
public void NotGranted_When_FlagsInsufficient()
|
||||
{
|
||||
@@ -94,6 +100,7 @@ public sealed class TriePermissionEvaluatorTests
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HistoryRead permission requires its own flag and is not implied by Read.</summary>
|
||||
[Fact]
|
||||
public void HistoryRead_Requires_Its_Own_Bit()
|
||||
{
|
||||
@@ -107,6 +114,7 @@ public sealed class TriePermissionEvaluatorTests
|
||||
historyRead.IsAllowed.ShouldBeFalse("HistoryRead uses its own NodePermissions flag, not Read");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cross-cluster sessions are denied.</summary>
|
||||
[Fact]
|
||||
public void CrossCluster_Session_Denied()
|
||||
{
|
||||
@@ -118,6 +126,7 @@ public sealed class TriePermissionEvaluatorTests
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stale sessions fail closed.</summary>
|
||||
[Fact]
|
||||
public void StaleSession_FailsClosed()
|
||||
{
|
||||
@@ -130,6 +139,7 @@ public sealed class TriePermissionEvaluatorTests
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authorization is denied when no cached trie exists for the cluster.</summary>
|
||||
[Fact]
|
||||
public void NoCachedTrie_ForCluster_Denied()
|
||||
{
|
||||
@@ -141,6 +151,7 @@ public sealed class TriePermissionEvaluatorTests
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stale generations evaluate against their bound session generation.</summary>
|
||||
[Fact]
|
||||
public void StaleGeneration_EvaluatesAgainst_SessionBoundGeneration()
|
||||
{
|
||||
@@ -161,6 +172,7 @@ public sealed class TriePermissionEvaluatorTests
|
||||
decision.Provenance.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stale generations fail closed when the bound generation is pruned.</summary>
|
||||
[Fact]
|
||||
public void StaleGeneration_FailsClosed_WhenBoundGenerationPruned()
|
||||
{
|
||||
@@ -179,6 +191,7 @@ public sealed class TriePermissionEvaluatorTests
|
||||
"a session bound to a generation absent from the cache must fail closed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the operation-to-permission mapping is total.</summary>
|
||||
[Fact]
|
||||
public void OperationToPermission_Mapping_IsTotal()
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class UserAuthorizationStateTests
|
||||
MembershipVersion = 1,
|
||||
};
|
||||
|
||||
/// <summary>Verifies that freshly resolved authorization state is neither stale nor needs refresh.</summary>
|
||||
[Fact]
|
||||
public void FreshlyResolved_Is_NotStale_NorNeedsRefresh()
|
||||
{
|
||||
@@ -28,6 +29,7 @@ public sealed class UserAuthorizationStateTests
|
||||
session.NeedsRefresh(Now.AddMinutes(1)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that refresh flag fires after the freshness interval expires.</summary>
|
||||
[Fact]
|
||||
public void NeedsRefresh_FiresAfter_FreshnessInterval()
|
||||
{
|
||||
@@ -36,6 +38,7 @@ public sealed class UserAuthorizationStateTests
|
||||
session.NeedsRefresh(Now.AddMinutes(16)).ShouldBeFalse("past freshness but also past the 15-min staleness ceiling — should be Stale, not NeedsRefresh");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the refresh flag fires within the production default freshness and staleness windows.</summary>
|
||||
[Fact]
|
||||
public void NeedsRefresh_FiresWithin_ProductionDefault_Windows()
|
||||
{
|
||||
@@ -50,6 +53,7 @@ public sealed class UserAuthorizationStateTests
|
||||
session.IsStale(Now.AddMinutes(10)).ShouldBeFalse("10 min is still within the 15-min staleness ceiling");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the refresh flag is true between the freshness and staleness windows.</summary>
|
||||
[Fact]
|
||||
public void NeedsRefresh_TrueBetween_Freshness_And_Staleness_Windows()
|
||||
{
|
||||
@@ -64,6 +68,7 @@ public sealed class UserAuthorizationStateTests
|
||||
session.IsStale(Now.AddMinutes(5)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the stale flag is true after the staleness window expires.</summary>
|
||||
[Fact]
|
||||
public void IsStale_TrueAfter_StalenessWindow()
|
||||
{
|
||||
|
||||
@@ -10,11 +10,21 @@ public sealed class DriverHostTests
|
||||
{
|
||||
private sealed class StubDriver(string id, bool failInit = false) : IDriver
|
||||
{
|
||||
/// <summary>Gets the driver instance identifier.</summary>
|
||||
public string DriverInstanceId { get; } = id;
|
||||
|
||||
/// <summary>Gets the driver type name.</summary>
|
||||
public string DriverType => "Stub";
|
||||
|
||||
/// <summary>Gets a value indicating whether the driver has been initialized.</summary>
|
||||
public bool Initialized { get; private set; }
|
||||
|
||||
/// <summary>Gets a value indicating whether the driver has been shut down.</summary>
|
||||
public bool ShutDown { get; private set; }
|
||||
|
||||
/// <summary>Initializes the driver asynchronously.</summary>
|
||||
/// <param name="_">Configuration data (unused in stub).</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task InitializeAsync(string _, CancellationToken ct)
|
||||
{
|
||||
if (failInit) throw new InvalidOperationException("boom");
|
||||
@@ -22,14 +32,28 @@ public sealed class DriverHostTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Reinitializes the driver asynchronously.</summary>
|
||||
/// <param name="_">Configuration data (unused in stub).</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Shuts down the driver asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task ShutdownAsync(CancellationToken ct) { ShutDown = true; return Task.CompletedTask; }
|
||||
|
||||
/// <summary>Gets the current health status of the driver.</summary>
|
||||
public DriverHealth GetHealth() =>
|
||||
new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Gets the memory footprint of the driver.</summary>
|
||||
public long GetMemoryFootprint() => 0;
|
||||
|
||||
/// <summary>Flushes optional caches asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registering a driver initializes it and tracks its health.</summary>
|
||||
[Fact]
|
||||
public async Task Register_initializes_driver_and_tracks_health()
|
||||
{
|
||||
@@ -43,6 +67,7 @@ public sealed class DriverHostTests
|
||||
host.GetHealth("d-1")!.State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that registration rethrows initialization failures but keeps the driver registered.</summary>
|
||||
[Fact]
|
||||
public async Task Register_rethrows_init_failure_but_keeps_driver_registered()
|
||||
{
|
||||
@@ -55,6 +80,7 @@ public sealed class DriverHostTests
|
||||
host.RegisteredDriverIds.ShouldContain("d-bad");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that duplicate driver registration throws an exception.</summary>
|
||||
[Fact]
|
||||
public async Task Duplicate_registration_throws()
|
||||
{
|
||||
@@ -65,6 +91,7 @@ public sealed class DriverHostTests
|
||||
host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unregistering a driver shuts it down and removes it.</summary>
|
||||
[Fact]
|
||||
public async Task Unregister_shuts_down_and_removes()
|
||||
{
|
||||
@@ -109,6 +136,7 @@ public sealed class DriverHostTests
|
||||
"RegisterAsync's awaited driver call must use ConfigureAwait(false) so the continuation does not post back to the captured context");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnregisterAsync does not capture the synchronization context.</summary>
|
||||
[Fact]
|
||||
public async Task UnregisterAsync_Does_Not_Capture_SynchronizationContext()
|
||||
{
|
||||
@@ -136,6 +164,7 @@ public sealed class DriverHostTests
|
||||
"UnregisterAsync's awaited shutdown call must use ConfigureAwait(false)");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DisposeAsync does not capture the synchronization context.</summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_Does_Not_Capture_SynchronizationContext()
|
||||
{
|
||||
@@ -196,14 +225,34 @@ public sealed class DriverHostTests
|
||||
/// <summary>Driver whose Initialize / Shutdown completions are caller-controlled via TCS.</summary>
|
||||
private sealed class TcsDriver(string id, TaskCompletionSource initTcs, TaskCompletionSource? shutdownTcs = null) : IDriver
|
||||
{
|
||||
/// <summary>Gets the driver instance identifier.</summary>
|
||||
public string DriverInstanceId { get; } = id;
|
||||
|
||||
/// <summary>Gets the driver type name.</summary>
|
||||
public string DriverType => "Tcs";
|
||||
|
||||
/// <summary>Initializes the driver asynchronously.</summary>
|
||||
/// <param name="_">Configuration data (unused in TCS driver).</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task InitializeAsync(string _, CancellationToken ct) => initTcs.Task;
|
||||
|
||||
/// <summary>Reinitializes the driver asynchronously.</summary>
|
||||
/// <param name="_">Configuration data (unused in TCS driver).</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Shuts down the driver asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task ShutdownAsync(CancellationToken ct) => (shutdownTcs ?? CompletedTcs).Task;
|
||||
|
||||
/// <summary>Gets the current health status of the driver.</summary>
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
|
||||
|
||||
/// <summary>Gets the memory footprint of the driver.</summary>
|
||||
public long GetMemoryFootprint() => 0;
|
||||
|
||||
/// <summary>Flushes optional caches asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
private static readonly TaskCompletionSource CompletedTcs = MakeCompleted();
|
||||
@@ -222,17 +271,28 @@ public sealed class DriverHostTests
|
||||
public int PostCount;
|
||||
public int SendCount;
|
||||
|
||||
/// <summary>Posts a callback to the work queue.</summary>
|
||||
/// <inheritdoc />
|
||||
public override void Post(SendOrPostCallback d, object? state)
|
||||
{
|
||||
Interlocked.Increment(ref PostCount);
|
||||
_queue.Enqueue(() => d(state));
|
||||
}
|
||||
|
||||
/// <summary>Sends a callback synchronously.</summary>
|
||||
/// <inheritdoc />
|
||||
public override void Send(SendOrPostCallback d, object? state)
|
||||
{
|
||||
Interlocked.Increment(ref SendCount);
|
||||
d(state);
|
||||
}
|
||||
|
||||
/// <summary>Attempts to dequeue a work item from the queue.</summary>
|
||||
/// <param name="work">The dequeued work item if one was available.</param>
|
||||
/// <returns>True if a work item was dequeued; otherwise false.</returns>
|
||||
public bool TryDequeue(out Action work) => _queue.TryDequeue(out work!);
|
||||
|
||||
/// <summary>Resets the post and send counts.</summary>
|
||||
public void Reset() { Interlocked.Exchange(ref PostCount, 0); Interlocked.Exchange(ref SendCount, 0); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public sealed class GenericDriverNodeManagerTests
|
||||
builder.Alarms["Heater.OverTemp"].Received.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-alarm variables do not register sinks in the alarm tracker.</summary>
|
||||
[Fact]
|
||||
public async Task Non_alarm_variables_do_not_register_sinks()
|
||||
{
|
||||
@@ -57,6 +58,7 @@ public sealed class GenericDriverNodeManagerTests
|
||||
nm.TrackedAlarmSources.ShouldNotContain("Tank.Level"); // the plain one
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm events with unknown source node IDs are silently dropped.</summary>
|
||||
[Fact]
|
||||
public async Task Unknown_source_node_id_is_dropped_silently()
|
||||
{
|
||||
@@ -71,6 +73,7 @@ public sealed class GenericDriverNodeManagerTests
|
||||
builder.Alarms.Values.All(s => s.Received.Count == 0).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the node manager unsubscribes from alarm events.</summary>
|
||||
[Fact]
|
||||
public async Task Dispose_unsubscribes_from_OnAlarmEvent()
|
||||
{
|
||||
@@ -117,6 +120,7 @@ public sealed class GenericDriverNodeManagerTests
|
||||
"the original alarm forwarder must be unsubscribed on the second build");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a second call to BuildAddressSpaceAsync clears the old sink registry.</summary>
|
||||
[Fact]
|
||||
public async Task Second_BuildAddressSpaceAsync_Clears_Old_Sink_Registry()
|
||||
{
|
||||
@@ -132,6 +136,7 @@ public sealed class GenericDriverNodeManagerTests
|
||||
countAfterSecond.ShouldBe(2, "second build must re-register exactly the same sources, not accumulate");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that calling BuildAddressSpaceAsync after disposal throws ObjectDisposedException.</summary>
|
||||
[Fact]
|
||||
public async Task BuildAddressSpaceAsync_After_Dispose_Throws_ObjectDisposedException()
|
||||
{
|
||||
@@ -164,16 +169,33 @@ public sealed class GenericDriverNodeManagerTests
|
||||
/// <summary>Driver whose DiscoverAsync throws — exercises the exception-isolation boundary.</summary>
|
||||
private sealed class ThrowingDiscoveryDriver : IDriver, ITagDiscovery
|
||||
{
|
||||
/// <summary>Gets the driver instance identifier.</summary>
|
||||
public string DriverInstanceId => "throwing";
|
||||
/// <summary>Gets the driver type name.</summary>
|
||||
public string DriverType => "Throwing";
|
||||
|
||||
/// <summary>Initializes the driver with configuration.</summary>
|
||||
/// <param name="_">Configuration JSON (unused in test double).</param>
|
||||
/// <param name="__">Cancellation token (unused in test double).</param>
|
||||
public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
|
||||
/// <summary>Reinitializes the driver with new configuration.</summary>
|
||||
/// <param name="_">Configuration JSON (unused in test double).</param>
|
||||
/// <param name="__">Cancellation token (unused in test double).</param>
|
||||
public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
|
||||
/// <summary>Shuts down the driver.</summary>
|
||||
/// <param name="_">Cancellation token (unused in test double).</param>
|
||||
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
|
||||
/// <summary>Gets the current health status of the driver.</summary>
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
|
||||
/// <summary>Gets the memory footprint of the driver.</summary>
|
||||
public long GetMemoryFootprint() => 0;
|
||||
/// <summary>Flushes optional caches in the driver.</summary>
|
||||
/// <param name="_">Cancellation token (unused in test double).</param>
|
||||
public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Discovers the address space by throwing an exception.</summary>
|
||||
/// <param name="builder">The builder used to construct the address space.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
=> throw new InvalidOperationException("discovery boom");
|
||||
}
|
||||
@@ -182,17 +204,35 @@ public sealed class GenericDriverNodeManagerTests
|
||||
|
||||
private sealed class FakeDriver : IDriver, ITagDiscovery, IAlarmSource
|
||||
{
|
||||
/// <summary>Gets the driver instance identifier.</summary>
|
||||
public string DriverInstanceId => "fake";
|
||||
/// <summary>Gets the driver type name.</summary>
|
||||
public string DriverType => "Fake";
|
||||
/// <summary>Occurs when an alarm event is raised.</summary>
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
/// <summary>Initializes the driver with configuration.</summary>
|
||||
/// <param name="driverConfigJson">Configuration JSON.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Reinitializes the driver with new configuration.</summary>
|
||||
/// <param name="driverConfigJson">Configuration JSON.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Shuts down the driver.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>Gets the current health status of the driver.</summary>
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
/// <summary>Gets the memory footprint of the driver.</summary>
|
||||
public long GetMemoryFootprint() => 0;
|
||||
/// <summary>Flushes optional caches in the driver.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Discovers the address space and registers alarm conditions.</summary>
|
||||
/// <param name="builder">The builder used to construct the address space.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
var folder = builder.Folder("Tank", "Tank");
|
||||
@@ -209,33 +249,63 @@ public sealed class GenericDriverNodeManagerTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Raises an alarm event with the given arguments.</summary>
|
||||
/// <param name="args">The alarm event arguments.</param>
|
||||
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
/// <summary>Subscribes to alarm events.</summary>
|
||||
/// <param name="_">Tag references to subscribe to (unused in test double).</param>
|
||||
/// <param name="__">Cancellation token (unused in test double).</param>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(IReadOnlyList<string> _, CancellationToken __)
|
||||
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
|
||||
/// <summary>Unsubscribes from alarm events.</summary>
|
||||
/// <param name="_">The subscription handle (unused in test double).</param>
|
||||
/// <param name="__">Cancellation token (unused in test double).</param>
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __) => Task.CompletedTask;
|
||||
/// <summary>Acknowledges alarm notifications.</summary>
|
||||
/// <param name="_">Alarm acknowledgement requests (unused in test double).</param>
|
||||
/// <param name="__">Cancellation token (unused in test double).</param>
|
||||
public Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Test double for IAlarmSubscriptionHandle.</summary>
|
||||
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets the diagnostic identifier for this subscription.</summary>
|
||||
public string DiagnosticId { get; } = diagnosticId;
|
||||
}
|
||||
|
||||
/// <summary>Test double for IAddressSpaceBuilder that records alarm sinks.</summary>
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>Gets the map of alarm sources to their sinks.</summary>
|
||||
public Dictionary<string, RecordingSink> Alarms { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Creates a folder in the address space.</summary>
|
||||
/// <param name="_">The contained name (unused in test double).</param>
|
||||
/// <param name="__">The display name (unused in test double).</param>
|
||||
public IAddressSpaceBuilder Folder(string _, string __) => this;
|
||||
|
||||
/// <summary>Creates a variable in the address space.</summary>
|
||||
/// <param name="_">The contained name (unused in test double).</param>
|
||||
/// <param name="__">The display name (unused in test double).</param>
|
||||
/// <param name="info">The driver attribute information.</param>
|
||||
public IVariableHandle Variable(string _, string __, DriverAttributeInfo info)
|
||||
=> new Handle(info.FullName, Alarms);
|
||||
|
||||
/// <summary>Adds a property to the current variable.</summary>
|
||||
/// <param name="_">The property name (unused in test double).</param>
|
||||
/// <param name="__">The data type (unused in test double).</param>
|
||||
/// <param name="___">The initial value (unused in test double).</param>
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
/// <summary>Test double for IVariableHandle.</summary>
|
||||
public sealed class Handle(string fullRef, Dictionary<string, RecordingSink> alarms) : IVariableHandle
|
||||
{
|
||||
/// <summary>Gets the full reference name for this variable.</summary>
|
||||
public string FullReference { get; } = fullRef;
|
||||
/// <summary>Marks this variable as an alarm condition and registers its sink.</summary>
|
||||
/// <param name="_">The alarm condition info (unused in test double).</param>
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo _)
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
@@ -244,9 +314,13 @@ public sealed class GenericDriverNodeManagerTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test double for IAlarmConditionSink that records transitions.</summary>
|
||||
public sealed class RecordingSink : IAlarmConditionSink
|
||||
{
|
||||
/// <summary>Gets the list of alarm transitions received by this sink.</summary>
|
||||
public List<AlarmEventArgs> Received { get; } = new();
|
||||
/// <summary>Records an alarm transition.</summary>
|
||||
/// <param name="args">The alarm event arguments.</param>
|
||||
public void OnTransition(AlarmEventArgs args) => Received.Add(args);
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Observability;
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class CapabilityInvokerEnrichmentTests
|
||||
{
|
||||
/// <summary>Verifies that InvokerExecute logs inside call site with structured properties.</summary>
|
||||
[Fact]
|
||||
public async Task InvokerExecute_LogsInsideCallSite_CarryStructuredProperties()
|
||||
{
|
||||
@@ -43,6 +44,7 @@ public sealed class CapabilityInvokerEnrichmentTests
|
||||
evt.Properties.ShouldContainKey("CorrelationId");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that InvokerExecute does not leak context outside the call site.</summary>
|
||||
[Fact]
|
||||
public async Task InvokerExecute_DoesNotLeak_ContextOutsideCallSite()
|
||||
{
|
||||
@@ -66,7 +68,10 @@ public sealed class CapabilityInvokerEnrichmentTests
|
||||
|
||||
private sealed class InMemorySink : ILogEventSink
|
||||
{
|
||||
/// <summary>Gets the list of captured log events.</summary>
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
/// <summary>Emits a log event by adding it to the captured events list.</summary>
|
||||
/// <param name="logEvent">The log event to emit.</param>
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Observability;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverHealthReportTests
|
||||
{
|
||||
/// <summary>Verifies that an empty fleet is healthy.</summary>
|
||||
[Fact]
|
||||
public void EmptyFleet_IsHealthy()
|
||||
{
|
||||
DriverHealthReport.Aggregate([]).ShouldBe(ReadinessVerdict.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a fleet with all healthy drivers is healthy.</summary>
|
||||
[Fact]
|
||||
public void AllHealthy_Fleet_IsHealthy()
|
||||
{
|
||||
@@ -24,6 +26,7 @@ public sealed class DriverHealthReportTests
|
||||
verdict.ShouldBe(ReadinessVerdict.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that any faulted driver trumps other states.</summary>
|
||||
[Fact]
|
||||
public void AnyFaulted_TrumpsEverything()
|
||||
{
|
||||
@@ -36,6 +39,8 @@ public sealed class DriverHealthReportTests
|
||||
verdict.ShouldBe(ReadinessVerdict.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that any not-ready driver without faults results in NotReady verdict.</summary>
|
||||
/// <param name="initializingState">The driver state representing a not-ready condition.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverState.Unknown)]
|
||||
[InlineData(DriverState.Initializing)]
|
||||
@@ -48,6 +53,7 @@ public sealed class DriverHealthReportTests
|
||||
verdict.ShouldBe(ReadinessVerdict.NotReady);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that any degraded driver without faults or not-ready results in Degraded verdict.</summary>
|
||||
[Fact]
|
||||
public void Any_Degraded_WithoutFaultedOrNotReady_IsDegraded()
|
||||
{
|
||||
@@ -58,6 +64,9 @@ public sealed class DriverHealthReportTests
|
||||
verdict.ShouldBe(ReadinessVerdict.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HTTP status codes match the readiness verdict state matrix.</summary>
|
||||
/// <param name="verdict">The readiness verdict to test.</param>
|
||||
/// <param name="expected">The expected HTTP status code.</param>
|
||||
[Theory]
|
||||
[InlineData(ReadinessVerdict.Healthy, 200)]
|
||||
[InlineData(ReadinessVerdict.Degraded, 200)]
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Observability;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LogContextEnricherTests
|
||||
{
|
||||
/// <summary>Verifies that the scope attaches all four log context properties.</summary>
|
||||
[Fact]
|
||||
public void Scope_Attaches_AllFour_Properties()
|
||||
{
|
||||
@@ -32,6 +33,7 @@ public sealed class LogContextEnricherTests
|
||||
evt.Properties["CorrelationId"].ToString().ShouldBe("\"abc123\"");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that scope disposal pops the log context properties.</summary>
|
||||
[Fact]
|
||||
public void Scope_Dispose_Pops_Properties()
|
||||
{
|
||||
@@ -52,6 +54,7 @@ public sealed class LogContextEnricherTests
|
||||
captured.Events[1].Properties.ContainsKey("DriverInstanceId").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that NewCorrelationId returns a 12-character hexadecimal string.</summary>
|
||||
[Fact]
|
||||
public void NewCorrelationId_Returns_12_Hex_Chars()
|
||||
{
|
||||
@@ -60,6 +63,8 @@ public sealed class LogContextEnricherTests
|
||||
id.ShouldMatch("^[0-9a-f]{12}$");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Push throws when DriverInstanceId is missing or empty.</summary>
|
||||
/// <param name="id">The driver instance ID value to test, or null.</param>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -72,7 +77,10 @@ public sealed class LogContextEnricherTests
|
||||
|
||||
private sealed class InMemorySink : ILogEventSink
|
||||
{
|
||||
/// <summary>Gets the list of captured log events.</summary>
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
/// <summary>Emits a log event to the sink.</summary>
|
||||
/// <param name="logEvent">The log event to emit.</param>
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentNodeWalkerTests
|
||||
{
|
||||
/// <summary>Verifies that walking empty content emits no nodes.</summary>
|
||||
[Fact]
|
||||
public void Walk_EmptyContent_EmitsNothing()
|
||||
{
|
||||
@@ -18,6 +19,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
rec.Children.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking emits Area, Line, and Equipment folders in unsorted order.</summary>
|
||||
[Fact]
|
||||
public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder()
|
||||
{
|
||||
@@ -36,6 +38,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking adds five identifier properties on equipment nodes, skipping null ZTag and SAPID.</summary>
|
||||
[Fact]
|
||||
public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid()
|
||||
{
|
||||
@@ -61,6 +64,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking adds ZTag and SAPID properties when present.</summary>
|
||||
[Fact]
|
||||
public void Walk_Adds_ZTag_And_SAPID_When_Present()
|
||||
{
|
||||
@@ -78,6 +82,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking materializes an Identification subfolder when any identification field is present.</summary>
|
||||
[Fact]
|
||||
public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent()
|
||||
{
|
||||
@@ -97,6 +102,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
identification.Properties.Select(p => p.BrowseName).ShouldContain("Model");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking omits the Identification subfolder when all identification fields are null.</summary>
|
||||
[Fact]
|
||||
public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull()
|
||||
{
|
||||
@@ -111,6 +117,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking emits a variable for each bound tag under equipment.</summary>
|
||||
[Fact]
|
||||
public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment()
|
||||
{
|
||||
@@ -132,6 +139,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking falls back to String type for unparseable data types.</summary>
|
||||
[Fact]
|
||||
public void Walk_FallsBack_To_String_For_Unparseable_DataType()
|
||||
{
|
||||
@@ -147,6 +155,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking emits virtual tag variables with Virtual source discriminator.</summary>
|
||||
[Fact]
|
||||
public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator()
|
||||
{
|
||||
@@ -173,6 +182,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking emits scripted alarm variables with ScriptedAlarm source and IsAlarm flag.</summary>
|
||||
[Fact]
|
||||
public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm()
|
||||
{
|
||||
@@ -199,6 +209,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking skips disabled virtual tags and alarms.</summary>
|
||||
[Fact]
|
||||
public void Walk_Skips_Disabled_VirtualTags_And_Alarms()
|
||||
{
|
||||
@@ -226,6 +237,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that walking with null virtual tags and scripted alarms is safe.</summary>
|
||||
[Fact]
|
||||
public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe()
|
||||
{
|
||||
@@ -240,6 +252,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that driver tag default NodeSourceKind is Driver.</summary>
|
||||
[Fact]
|
||||
public void Driver_tag_default_NodeSourceKind_is_Driver()
|
||||
{
|
||||
@@ -258,6 +271,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ExtractFullName unwraps a JSON object with FullName field.</summary>
|
||||
[Fact]
|
||||
public void ExtractFullName_unwraps_json_object_with_FullName_field()
|
||||
{
|
||||
@@ -266,6 +280,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
.ShouldBe("MESReceiver_001.MoveInBatchID");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ExtractFullName handles S7-style extra fields.</summary>
|
||||
[Fact]
|
||||
public void ExtractFullName_handles_S7_style_extra_fields()
|
||||
{
|
||||
@@ -274,6 +289,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
.ShouldBe("DB1_DBW0");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ExtractFullName returns raw string when input is not JSON.</summary>
|
||||
[Fact]
|
||||
public void ExtractFullName_returns_raw_when_not_json()
|
||||
{
|
||||
@@ -282,6 +298,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
EquipmentNodeWalker.ExtractFullName("raw-tag-ref").ShouldBe("raw-tag-ref");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ExtractFullName returns raw string when JSON is missing FullName field.</summary>
|
||||
[Fact]
|
||||
public void ExtractFullName_returns_raw_when_json_missing_FullName_field()
|
||||
{
|
||||
@@ -289,6 +306,7 @@ public sealed class EquipmentNodeWalkerTests
|
||||
.ShouldBe("{\"Address\":\"DB1.DBW0\"}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that driver tag FullName passes through from TagConfig JSON.</summary>
|
||||
[Fact]
|
||||
public void Driver_tag_FullName_passes_through_from_TagConfig_json()
|
||||
{
|
||||
@@ -347,13 +365,21 @@ public sealed class EquipmentNodeWalkerTests
|
||||
|
||||
// ----- recording IAddressSpaceBuilder -----
|
||||
|
||||
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
|
||||
private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>Gets the browse name of this node.</summary>
|
||||
public string BrowseName { get; } = browseName;
|
||||
/// <summary>Gets the list of child nodes.</summary>
|
||||
public List<RecordingBuilder> Children { get; } = new();
|
||||
/// <summary>Gets the list of variables.</summary>
|
||||
public List<RecordingVariable> Variables { get; } = new();
|
||||
/// <summary>Gets the list of properties.</summary>
|
||||
public List<RecordingProperty> Properties { get; } = new();
|
||||
|
||||
/// <summary>Creates a folder child node.</summary>
|
||||
/// <param name="name">The browse name of the folder.</param>
|
||||
/// <param name="_">The display name (unused).</param>
|
||||
public IAddressSpaceBuilder Folder(string name, string _)
|
||||
{
|
||||
var child = new RecordingBuilder(name);
|
||||
@@ -361,6 +387,10 @@ public sealed class EquipmentNodeWalkerTests
|
||||
return child;
|
||||
}
|
||||
|
||||
/// <summary>Creates a variable node.</summary>
|
||||
/// <param name="name">The browse name of the variable.</param>
|
||||
/// <param name="_">The display name (unused).</param>
|
||||
/// <param name="attr">The attribute information for the variable.</param>
|
||||
public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
|
||||
{
|
||||
var v = new RecordingVariable(name, attr);
|
||||
@@ -368,15 +398,24 @@ public sealed class EquipmentNodeWalkerTests
|
||||
return v;
|
||||
}
|
||||
|
||||
/// <summary>Adds a property to the node.</summary>
|
||||
/// <param name="name">The browse name of the property.</param>
|
||||
/// <param name="_">The data type (unused).</param>
|
||||
/// <param name="value">The value of the property.</param>
|
||||
public void AddProperty(string name, DriverDataType _, object? value) =>
|
||||
Properties.Add(new RecordingProperty(name, value));
|
||||
}
|
||||
|
||||
/// <summary>Recorded property for test verification.</summary>
|
||||
private sealed record RecordingProperty(string BrowseName, object? Value);
|
||||
|
||||
/// <summary>Recorded variable for test verification.</summary>
|
||||
private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle
|
||||
{
|
||||
/// <summary>Gets the full reference of the variable.</summary>
|
||||
public string FullReference => AttributeInfo.FullName;
|
||||
/// <summary>Marks the variable as an alarm condition.</summary>
|
||||
/// <param name="info">The alarm condition information.</param>
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,20 +9,35 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class IdentificationFolderBuilderTests
|
||||
{
|
||||
/// <summary>Records folder and property additions for test verification.</summary>
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>Gets or sets the list of added folders.</summary>
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
|
||||
/// <summary>Gets or sets the list of added properties.</summary>
|
||||
public List<(string BrowseName, DriverDataType DataType, object? Value)> Properties { get; } = [];
|
||||
|
||||
/// <summary>Records a folder and returns this builder for chaining.</summary>
|
||||
/// <param name="browseName">The browse name of the folder.</param>
|
||||
/// <param name="displayName">The display name of the folder.</param>
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add((browseName, displayName));
|
||||
return this; // flat recording — identification fields land in the same bucket
|
||||
}
|
||||
|
||||
/// <summary>Not supported in test context.</summary>
|
||||
/// <param name="browseName">The browse name of the variable.</param>
|
||||
/// <param name="displayName">The display name of the variable.</param>
|
||||
/// <param name="attributeInfo">The attribute information.</param>
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> throw new NotSupportedException("Identification fields use AddProperty, not Variable");
|
||||
|
||||
/// <summary>Records a property addition.</summary>
|
||||
/// <param name="browseName">The browse name of the property.</param>
|
||||
/// <param name="dataType">The data type of the property.</param>
|
||||
/// <param name="value">The property value.</param>
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
=> Properties.Add((browseName, dataType, value));
|
||||
}
|
||||
@@ -54,12 +69,14 @@ public sealed class IdentificationFolderBuilderTests
|
||||
DeviceManualUri = "https://siemens.example/manual",
|
||||
};
|
||||
|
||||
/// <summary>Verifies that HasAnyFields returns false when all fields are null.</summary>
|
||||
[Fact]
|
||||
public void HasAnyFields_AllNull_ReturnsFalse()
|
||||
{
|
||||
IdentificationFolderBuilder.HasAnyFields(EmptyEquipment()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that HasAnyFields returns true when at least one field is non-null.</summary>
|
||||
[Fact]
|
||||
public void HasAnyFields_OneNonNull_ReturnsTrue()
|
||||
{
|
||||
@@ -68,6 +85,7 @@ public sealed class IdentificationFolderBuilderTests
|
||||
IdentificationFolderBuilder.HasAnyFields(eq).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Build returns null and emits no folder when all fields are null.</summary>
|
||||
[Fact]
|
||||
public void Build_AllNull_ReturnsNull_AndDoesNotEmit_Folder()
|
||||
{
|
||||
@@ -80,6 +98,7 @@ public sealed class IdentificationFolderBuilderTests
|
||||
builder.Properties.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Build emits all nine fields when fully populated.</summary>
|
||||
[Fact]
|
||||
public void Build_FullyPopulated_EmitsAllNineFields()
|
||||
{
|
||||
@@ -98,6 +117,7 @@ public sealed class IdentificationFolderBuilderTests
|
||||
"property order matches decision #139 exactly");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Build emits only non-null fields.</summary>
|
||||
[Fact]
|
||||
public void Build_OnlyNonNull_Are_Emitted()
|
||||
{
|
||||
@@ -114,6 +134,7 @@ public sealed class IdentificationFolderBuilderTests
|
||||
["Manufacturer", "SerialNumber", "YearOfConstruction"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that YearOfConstruction maps short to Int32 DriverDataType.</summary>
|
||||
[Fact]
|
||||
public void YearOfConstruction_Maps_Short_To_Int32_DriverDataType()
|
||||
{
|
||||
@@ -128,6 +149,7 @@ public sealed class IdentificationFolderBuilderTests
|
||||
prop.Value.ShouldBe(2023, "short is widened to int for OPC UA Int32 representation");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that string values round-trip through Build.</summary>
|
||||
[Fact]
|
||||
public void Build_StringValues_RoundTrip()
|
||||
{
|
||||
@@ -140,6 +162,7 @@ public sealed class IdentificationFolderBuilderTests
|
||||
builder.Properties.Single(p => p.BrowseName == "DeviceManualUri").Value.ShouldBe("https://siemens.example/manual");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that field names match decision 139 exactly.</summary>
|
||||
[Fact]
|
||||
public void FieldNames_Match_Decision139_Exactly()
|
||||
{
|
||||
@@ -150,6 +173,7 @@ public sealed class IdentificationFolderBuilderTests
|
||||
"ManufacturerUri", "DeviceManualUri"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the folder name is Identification.</summary>
|
||||
[Fact]
|
||||
public void FolderName_Is_Identification()
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ public sealed class AlarmSurfaceInvokerTests
|
||||
{
|
||||
private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A };
|
||||
|
||||
/// <summary>Verifies SubscribeAsync on an empty list returns empty without calling the driver.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_EmptyList_ReturnsEmpty_WithoutDriverCall()
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public sealed class AlarmSurfaceInvokerTests
|
||||
driver.SubscribeCallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies SubscribeAsync with no resolver routes through the default host.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_SingleHost_RoutesThroughDefaultHost()
|
||||
{
|
||||
@@ -35,6 +37,7 @@ public sealed class AlarmSurfaceInvokerTests
|
||||
driver.LastSubscribedIds.ShouldBe(["src-1", "src-2"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies SubscribeAsync fans out correctly to multiple hosts based on resolver.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_MultiHost_FansOutByResolvedHost()
|
||||
{
|
||||
@@ -53,6 +56,7 @@ public sealed class AlarmSurfaceInvokerTests
|
||||
driver.SubscribeCallCount.ShouldBe(2); // one driver call per host
|
||||
}
|
||||
|
||||
/// <summary>Verifies AcknowledgeAsync does not retry on failure.</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_DoesNotRetry_OnFailure()
|
||||
{
|
||||
@@ -65,6 +69,7 @@ public sealed class AlarmSurfaceInvokerTests
|
||||
driver.AcknowledgeCallCount.ShouldBe(1, "AlarmAcknowledge must not retry — decision #143");
|
||||
}
|
||||
|
||||
/// <summary>Verifies SubscribeAsync retries on transient failures.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_Retries_Transient_Failures()
|
||||
{
|
||||
@@ -106,6 +111,7 @@ public sealed class AlarmSurfaceInvokerTests
|
||||
driver.UnsubscribeCallCount.ShouldBe(2, "one unsubscribe per subscription handle (per host)");
|
||||
}
|
||||
|
||||
/// <summary>Verifies UnsubscribeAsync with no resolver uses the default host.</summary>
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_SingleHost_UsesDefaultHost()
|
||||
{
|
||||
@@ -131,15 +137,31 @@ public sealed class AlarmSurfaceInvokerTests
|
||||
return new AlarmSurfaceInvoker(invoker, driver, defaultHost, resolver);
|
||||
}
|
||||
|
||||
/// <summary>Fake alarm source for testing.</summary>
|
||||
private sealed class FakeAlarmSource : IAlarmSource
|
||||
{
|
||||
/// <summary>Gets the number of times SubscribeAlarmsAsync was called.</summary>
|
||||
public int SubscribeCallCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times UnsubscribeAlarmsAsync was called.</summary>
|
||||
public int UnsubscribeCallCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times AcknowledgeAsync was called.</summary>
|
||||
public int AcknowledgeCallCount { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the number of failures before SubscribeAlarmsAsync succeeds.</summary>
|
||||
public int SubscribeFailuresBeforeSuccess { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether AcknowledgeAsync should throw.</summary>
|
||||
public bool AcknowledgeShouldThrow { get; set; }
|
||||
|
||||
/// <summary>Gets the source node IDs from the most recent SubscribeAlarmsAsync call.</summary>
|
||||
public IReadOnlyList<string> LastSubscribedIds { get; private set; } = [];
|
||||
|
||||
/// <summary>Subscribes to alarms.</summary>
|
||||
/// <param name="sourceNodeIds">The source node IDs to subscribe to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An alarm subscription handle.</returns>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -150,12 +172,20 @@ public sealed class AlarmSurfaceInvokerTests
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(new StubHandle($"h-{SubscribeCallCount}"));
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from alarms.</summary>
|
||||
/// <param name="handle">The subscription handle to unsubscribe.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
UnsubscribeCallCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Acknowledges alarms.</summary>
|
||||
/// <param name="acknowledgements">The alarm acknowledgements to process.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -164,13 +194,21 @@ public sealed class AlarmSurfaceInvokerTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Occurs when an alarm event is raised.</summary>
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent { add { } remove { } }
|
||||
}
|
||||
|
||||
/// <summary>Stub alarm subscription handle for testing.</summary>
|
||||
/// <param name="DiagnosticId">Diagnostic identifier for the handle.</param>
|
||||
private sealed record StubHandle(string DiagnosticId) : IAlarmSubscriptionHandle;
|
||||
|
||||
/// <summary>Stub host resolver for testing multi-host scenarios.</summary>
|
||||
/// <param name="map">The map of source node IDs to host names.</param>
|
||||
private sealed class StubResolver(Dictionary<string, string> map) : IPerCallHostResolver
|
||||
{
|
||||
/// <summary>Resolves the host for the given full reference.</summary>
|
||||
/// <param name="fullReference">The full reference to resolve.</param>
|
||||
/// <returns>The resolved host name.</returns>
|
||||
public string ResolveHost(string fullReference) => map[fullReference];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class CapabilityInvokerTests
|
||||
DriverResilienceOptions options) =>
|
||||
new(builder, "drv-test", () => options);
|
||||
|
||||
/// <summary>Verifies that the capability invoker returns the value from the call site.</summary>
|
||||
[Fact]
|
||||
public async Task Read_ReturnsValue_FromCallSite()
|
||||
{
|
||||
@@ -27,6 +28,7 @@ public sealed class CapabilityInvokerTests
|
||||
result.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the capability invoker retries on transient failures.</summary>
|
||||
[Fact]
|
||||
public async Task Read_Retries_OnTransientFailure()
|
||||
{
|
||||
@@ -49,6 +51,7 @@ public sealed class CapabilityInvokerTests
|
||||
attempts.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-idempotent writes do not retry even when the policy has retries configured.</summary>
|
||||
[Fact]
|
||||
public async Task Write_NonIdempotent_DoesNotRetry_EvenWhenPolicyHasRetries()
|
||||
{
|
||||
@@ -81,6 +84,7 @@ public sealed class CapabilityInvokerTests
|
||||
attempts.ShouldBe(1, "non-idempotent write must never replay");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that idempotent writes retry when the policy has retries configured.</summary>
|
||||
[Fact]
|
||||
public async Task Write_Idempotent_Retries_WhenPolicyHasRetries()
|
||||
{
|
||||
@@ -111,6 +115,7 @@ public sealed class CapabilityInvokerTests
|
||||
attempts.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writes do not retry when the policy has zero retries configured.</summary>
|
||||
[Fact]
|
||||
public async Task Write_Default_DoesNotRetry_WhenPolicyHasZeroRetries()
|
||||
{
|
||||
@@ -137,6 +142,7 @@ public sealed class CapabilityInvokerTests
|
||||
attempts.ShouldBe(1, "tier-A default for Write is RetryCount=0");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that different hosts are honored independently in the resilience pipeline.</summary>
|
||||
[Fact]
|
||||
public async Task Execute_HonorsDifferentHosts_Independently()
|
||||
{
|
||||
|
||||
+17
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverResilienceOptionsParserTests
|
||||
{
|
||||
/// <summary>Verifies that null JSON returns pure tier defaults.</summary>
|
||||
[Fact]
|
||||
public void NullJson_ReturnsPureTierDefaults()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that whitespace JSON returns defaults.</summary>
|
||||
[Fact]
|
||||
public void WhitespaceJson_ReturnsDefaults()
|
||||
{
|
||||
@@ -26,6 +28,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
diag.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that malformed JSON falls back with diagnostic.</summary>
|
||||
[Fact]
|
||||
public void MalformedJson_FallsBack_WithDiagnostic()
|
||||
{
|
||||
@@ -38,6 +41,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty object returns defaults.</summary>
|
||||
[Fact]
|
||||
public void EmptyObject_ReturnsDefaults()
|
||||
{
|
||||
@@ -48,6 +52,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Read override is merged into tier defaults.</summary>
|
||||
[Fact]
|
||||
public void ReadOverride_MergedIntoTierDefaults()
|
||||
{
|
||||
@@ -72,6 +77,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that partial policy fills missing fields from tier default.</summary>
|
||||
[Fact]
|
||||
public void PartialPolicy_FillsMissingFieldsFromTierDefault()
|
||||
{
|
||||
@@ -92,6 +98,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
read.BreakerFailureThreshold.ShouldBe(tierDefault.BreakerFailureThreshold);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that bulkhead overrides are honored.</summary>
|
||||
[Fact]
|
||||
public void BulkheadOverrides_AreHonored()
|
||||
{
|
||||
@@ -105,6 +112,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
options.BulkheadMaxQueue.ShouldBe(500);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unknown capability surfaces in diagnostic but does not fail.</summary>
|
||||
[Fact]
|
||||
public void UnknownCapability_Surfaces_InDiagnostic_ButDoesNotFail()
|
||||
{
|
||||
@@ -125,6 +133,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that property names are case insensitive.</summary>
|
||||
[Fact]
|
||||
public void PropertyNames_AreCaseInsensitive()
|
||||
{
|
||||
@@ -137,6 +146,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
options.BulkheadMaxConcurrent.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that capability name is case insensitive.</summary>
|
||||
[Fact]
|
||||
public void CapabilityName_IsCaseInsensitive()
|
||||
{
|
||||
@@ -150,6 +160,8 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(99);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that every tier with empty JSON round-trips its defaults.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
@@ -164,6 +176,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecycleIntervalSeconds on Tier C with positive value parses and surfaces.</summary>
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_TierC_PositiveValue_ParsesAndSurfaces()
|
||||
{
|
||||
@@ -174,6 +187,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
options.RecycleIntervalSeconds.ShouldBe(3600);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecycleIntervalSeconds when null defaults to null.</summary>
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_Null_DefaultsToNull()
|
||||
{
|
||||
@@ -181,6 +195,8 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
options.RecycleIntervalSeconds.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecycleIntervalSeconds on Tier A or B is rejected with diagnostic.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
@@ -196,6 +212,7 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
diag.ShouldContain("Tier C only");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecycleIntervalSeconds with non-positive value is rejected with diagnostic.</summary>
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_NonPositive_Rejected_With_Diagnostic()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverResilienceOptionsTests
|
||||
{
|
||||
/// <summary>Verifies that tier defaults cover every capability.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
@@ -20,6 +22,8 @@ public sealed class DriverResilienceOptionsTests
|
||||
defaults.ShouldContainKey(capability);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write never retries by default.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
@@ -30,6 +34,8 @@ public sealed class DriverResilienceOptionsTests
|
||||
defaults[DriverCapability.Write].RetryCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm acknowledge never retries by default.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
@@ -40,6 +46,9 @@ public sealed class DriverResilienceOptionsTests
|
||||
defaults[DriverCapability.AlarmAcknowledge].RetryCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that idempotent capabilities retry by default.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
/// <param name="capability">The driver capability to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A, DriverCapability.Read)]
|
||||
[InlineData(DriverTier.A, DriverCapability.HistoryRead)]
|
||||
@@ -52,6 +61,7 @@ public sealed class DriverResilienceOptionsTests
|
||||
defaults[capability].RetryCount.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that TierC disables circuit breaker deferring to supervisor.</summary>
|
||||
[Fact]
|
||||
public void TierC_DisablesCircuitBreaker_DeferringToSupervisor()
|
||||
{
|
||||
@@ -61,6 +71,8 @@ public sealed class DriverResilienceOptionsTests
|
||||
policy.BreakerFailureThreshold.ShouldBe(0, "Tier C breaker is handled by the Proxy supervisor (decision #68)");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that TierA and TierB enable circuit breaker.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
@@ -72,6 +84,7 @@ public sealed class DriverResilienceOptionsTests
|
||||
policy.BreakerFailureThreshold.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolve uses tier defaults when no override is set.</summary>
|
||||
[Fact]
|
||||
public void Resolve_Uses_TierDefaults_When_NoOverride()
|
||||
{
|
||||
@@ -82,6 +95,7 @@ public sealed class DriverResilienceOptionsTests
|
||||
resolved.ShouldBe(DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolve uses override when configured.</summary>
|
||||
[Fact]
|
||||
public void Resolve_Uses_Override_When_Configured()
|
||||
{
|
||||
@@ -106,6 +120,7 @@ public sealed class DriverResilienceOptionsTests
|
||||
/// enum-only addition that forgets to update <c>GetTierDefaults</c> would otherwise blow up
|
||||
/// on the hot path with <see cref="KeyNotFoundException"/>.
|
||||
/// </summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
|
||||
+14
@@ -12,6 +12,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
{
|
||||
private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A };
|
||||
|
||||
/// <summary>Verifies that read operations retry transient failures.</summary>
|
||||
[Fact]
|
||||
public async Task Read_Retries_Transient_Failures()
|
||||
{
|
||||
@@ -29,6 +30,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
attempts.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write operations do not retry on failure.</summary>
|
||||
[Fact]
|
||||
public async Task Write_DoesNotRetry_OnFailure()
|
||||
{
|
||||
@@ -50,6 +52,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
ex.Message.ShouldBe("boom");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm acknowledge operations do not retry on failure.</summary>
|
||||
[Fact]
|
||||
public async Task AlarmAcknowledge_DoesNotRetry_OnFailure()
|
||||
{
|
||||
@@ -70,6 +73,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
attempts.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that pipelines are isolated per host.</summary>
|
||||
[Fact]
|
||||
public void Pipeline_IsIsolated_PerHost()
|
||||
{
|
||||
@@ -83,6 +87,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
builder.CachedPipelineCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that pipelines are reused for the same driver, host, and capability triple.</summary>
|
||||
[Fact]
|
||||
public void Pipeline_IsReused_ForSameTriple()
|
||||
{
|
||||
@@ -96,6 +101,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
builder.CachedPipelineCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that pipelines are isolated per capability.</summary>
|
||||
[Fact]
|
||||
public void Pipeline_IsIsolated_PerCapability()
|
||||
{
|
||||
@@ -108,6 +114,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
read.ShouldNotBeSameAs(write);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a dead host does not open the breaker for a sibling host.</summary>
|
||||
[Fact]
|
||||
public async Task DeadHost_DoesNotOpenBreaker_ForSiblingHost()
|
||||
{
|
||||
@@ -138,6 +145,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
liveAttempts.ShouldBe(1, "healthy sibling host must not be affected by dead peer");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the circuit breaker opens after the failure threshold on tier A.</summary>
|
||||
[Fact]
|
||||
public async Task CircuitBreaker_Opens_AfterFailureThreshold_OnTierA()
|
||||
{
|
||||
@@ -162,6 +170,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that timeout cancels slow operations.</summary>
|
||||
[Fact]
|
||||
public async Task Timeout_Cancels_SlowOperation()
|
||||
{
|
||||
@@ -183,6 +192,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalidate removes only the matching instance.</summary>
|
||||
[Fact]
|
||||
public void Invalidate_Removes_OnlyMatchingInstance()
|
||||
{
|
||||
@@ -200,6 +210,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
builder.CachedPipelineCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cancellation is not retried.</summary>
|
||||
[Fact]
|
||||
public async Task Cancellation_IsNot_Retried()
|
||||
{
|
||||
@@ -220,6 +231,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
attempts.ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tracker records failure on every retry.</summary>
|
||||
[Fact]
|
||||
public async Task Tracker_RecordsFailure_OnEveryRetry()
|
||||
{
|
||||
@@ -240,6 +252,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
snap!.ConsecutiveFailures.ShouldBe(retryCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tracker stamps the breaker open when it trips.</summary>
|
||||
[Fact]
|
||||
public async Task Tracker_StampsBreakerOpen_WhenBreakerTrips()
|
||||
{
|
||||
@@ -263,6 +276,7 @@ public sealed class DriverResiliencePipelineBuilderTests
|
||||
snap!.LastBreakerOpenUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the tracker isolates counters per host.</summary>
|
||||
[Fact]
|
||||
public async Task Tracker_IsolatesCounters_PerHost()
|
||||
{
|
||||
|
||||
+9
@@ -9,6 +9,7 @@ public sealed class DriverResilienceStatusTrackerTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>Verifies that TryGet returns null before any write operations.</summary>
|
||||
[Fact]
|
||||
public void TryGet_Returns_Null_Before_AnyWrite()
|
||||
{
|
||||
@@ -17,6 +18,7 @@ public sealed class DriverResilienceStatusTrackerTests
|
||||
tracker.TryGet("drv", "host").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecordFailure accumulates consecutive failures.</summary>
|
||||
[Fact]
|
||||
public void RecordFailure_Accumulates_ConsecutiveFailures()
|
||||
{
|
||||
@@ -29,6 +31,7 @@ public sealed class DriverResilienceStatusTrackerTests
|
||||
tracker.TryGet("drv", "host")!.ConsecutiveFailures.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecordSuccess resets consecutive failures to zero.</summary>
|
||||
[Fact]
|
||||
public void RecordSuccess_Resets_ConsecutiveFailures()
|
||||
{
|
||||
@@ -41,6 +44,7 @@ public sealed class DriverResilienceStatusTrackerTests
|
||||
tracker.TryGet("drv", "host")!.ConsecutiveFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecordBreakerOpen populates the LastBreakerOpenUtc timestamp.</summary>
|
||||
[Fact]
|
||||
public void RecordBreakerOpen_Populates_LastBreakerOpenUtc()
|
||||
{
|
||||
@@ -51,6 +55,7 @@ public sealed class DriverResilienceStatusTrackerTests
|
||||
tracker.TryGet("drv", "host")!.LastBreakerOpenUtc.ShouldBe(Now);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecordRecycle populates the LastRecycleUtc timestamp.</summary>
|
||||
[Fact]
|
||||
public void RecordRecycle_Populates_LastRecycleUtc()
|
||||
{
|
||||
@@ -61,6 +66,7 @@ public sealed class DriverResilienceStatusTrackerTests
|
||||
tracker.TryGet("drv", "host")!.LastRecycleUtc.ShouldBe(Now);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that RecordFootprint captures baseline and current memory usage.</summary>
|
||||
[Fact]
|
||||
public void RecordFootprint_CapturesBaselineAndCurrent()
|
||||
{
|
||||
@@ -73,6 +79,7 @@ public sealed class DriverResilienceStatusTrackerTests
|
||||
snap.CurrentFootprintBytes.ShouldBe(150_000_000);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that different hosts are tracked independently.</summary>
|
||||
[Fact]
|
||||
public void DifferentHosts_AreIndependent()
|
||||
{
|
||||
@@ -86,6 +93,7 @@ public sealed class DriverResilienceStatusTrackerTests
|
||||
tracker.TryGet("drv", "host-b")!.ConsecutiveFailures.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Snapshot returns all tracked driver-host pairs.</summary>
|
||||
[Fact]
|
||||
public void Snapshot_ReturnsAll_TrackedPairs()
|
||||
{
|
||||
@@ -99,6 +107,7 @@ public sealed class DriverResilienceStatusTrackerTests
|
||||
snapshot.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent writes do not lose failure records.</summary>
|
||||
[Fact]
|
||||
public void ConcurrentWrites_DoNotLose_Failures()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class FlakeyDriverIntegrationTests
|
||||
{
|
||||
/// <summary>Verifies read succeeds after transient failures with retries.</summary>
|
||||
[Fact]
|
||||
public async Task Read_SurfacesSuccess_AfterTransientFailures()
|
||||
{
|
||||
@@ -41,6 +42,7 @@ public sealed class FlakeyDriverIntegrationTests
|
||||
result[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies non-idempotent write fails on first failure without replay.</summary>
|
||||
[Fact]
|
||||
public async Task Write_NonIdempotent_FailsOnFirstFailure_NoReplay()
|
||||
{
|
||||
@@ -65,6 +67,7 @@ public sealed class FlakeyDriverIntegrationTests
|
||||
flaky.WriteAttempts.ShouldBe(1, "non-idempotent write must never replay (decision #44)");
|
||||
}
|
||||
|
||||
/// <summary>Verifies idempotent write retries until success.</summary>
|
||||
[Fact]
|
||||
public async Task Write_Idempotent_RetriesUntilSuccess()
|
||||
{
|
||||
@@ -89,6 +92,7 @@ public sealed class FlakeyDriverIntegrationTests
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies multiple hosts have independent failure counts and circuit breakers.</summary>
|
||||
[Fact]
|
||||
public async Task MultipleHosts_OnOneDriver_HaveIndependentFailureCounts()
|
||||
{
|
||||
@@ -116,20 +120,31 @@ public sealed class FlakeyDriverIntegrationTests
|
||||
liveAttempts.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Driver that fails reads/writes for a configurable number of attempts.</summary>
|
||||
private sealed class FlakeyDriver : IReadable, IWritable
|
||||
{
|
||||
private readonly int _failReadsBeforeIndex;
|
||||
private readonly int _failWritesBeforeIndex;
|
||||
|
||||
/// <summary>Gets the number of read attempts made.</summary>
|
||||
public int ReadAttempts { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of write attempts made.</summary>
|
||||
public int WriteAttempts { get; private set; }
|
||||
|
||||
/// <summary>Initializes a flaky driver with configurable failure counts.</summary>
|
||||
/// <param name="failReadsBeforeIndex">Fail reads until this attempt number.</param>
|
||||
/// <param name="failWritesBeforeIndex">Fail writes until this attempt number.</param>
|
||||
public FlakeyDriver(int failReadsBeforeIndex = 0, int failWritesBeforeIndex = 0)
|
||||
{
|
||||
_failReadsBeforeIndex = failReadsBeforeIndex;
|
||||
_failWritesBeforeIndex = failWritesBeforeIndex;
|
||||
}
|
||||
|
||||
/// <summary>Reads values, failing transiently until the threshold.</summary>
|
||||
/// <param name="fullReferences">Full references to read.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Data value snapshots.</returns>
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -145,6 +160,10 @@ public sealed class FlakeyDriverIntegrationTests
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <summary>Writes values, failing transiently until the threshold.</summary>
|
||||
/// <param name="writes">The write requests.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Write results.</returns>
|
||||
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InFlightCounterTests
|
||||
{
|
||||
/// <summary>Verifies that starting and completing a call nets to zero.</summary>
|
||||
[Fact]
|
||||
public void StartThenComplete_NetsToZero()
|
||||
{
|
||||
@@ -18,6 +19,7 @@ public sealed class InFlightCounterTests
|
||||
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nested starts sum the depth.</summary>
|
||||
[Fact]
|
||||
public void NestedStarts_SumDepth()
|
||||
{
|
||||
@@ -32,6 +34,7 @@ public sealed class InFlightCounterTests
|
||||
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that completing before start is clamped to zero.</summary>
|
||||
[Fact]
|
||||
public void CompleteBeforeStart_ClampedToZero()
|
||||
{
|
||||
@@ -42,6 +45,7 @@ public sealed class InFlightCounterTests
|
||||
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that different hosts track independently.</summary>
|
||||
[Fact]
|
||||
public void DifferentHosts_TrackIndependently()
|
||||
{
|
||||
@@ -54,6 +58,7 @@ public sealed class InFlightCounterTests
|
||||
tracker.TryGet("drv", "host-b")!.CurrentInFlight.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent starts do not lose count.</summary>
|
||||
[Fact]
|
||||
public void ConcurrentStarts_DoNotLose_Count()
|
||||
{
|
||||
@@ -63,6 +68,7 @@ public sealed class InFlightCounterTests
|
||||
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(500);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that CapabilityInvoker increments the tracker during execution.</summary>
|
||||
[Fact]
|
||||
public async Task CapabilityInvoker_IncrementsTracker_DuringExecution()
|
||||
{
|
||||
@@ -90,6 +96,7 @@ public sealed class InFlightCounterTests
|
||||
tracker.TryGet("drv-live", "plc-1")!.CurrentInFlight.ShouldBe(0, "post-call, counter decremented");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that CapabilityInvoker decrements the counter on exception.</summary>
|
||||
[Fact]
|
||||
public async Task CapabilityInvoker_ExceptionPath_DecrementsCounter()
|
||||
{
|
||||
@@ -111,6 +118,7 @@ public sealed class InFlightCounterTests
|
||||
"finally-block must decrement even when call-site throws");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that CapabilityInvoker without a tracker does not throw.</summary>
|
||||
[Fact]
|
||||
public async Task CapabilityInvoker_WithoutTracker_DoesNotThrow()
|
||||
{
|
||||
|
||||
+10
@@ -17,11 +17,18 @@ public sealed class PerCallHostResolverDispatchTests
|
||||
private sealed class StaticResolver : IPerCallHostResolver
|
||||
{
|
||||
private readonly Dictionary<string, string> _map;
|
||||
|
||||
/// <summary>Initializes a new instance of StaticResolver with a predefined mapping.</summary>
|
||||
/// <param name="map">The mapping of full references to host names.</param>
|
||||
public StaticResolver(Dictionary<string, string> map) => _map = map;
|
||||
|
||||
/// <summary>Resolves a host name from the static mapping.</summary>
|
||||
/// <param name="fullReference">The full reference to resolve.</param>
|
||||
public string ResolveHost(string fullReference) =>
|
||||
_map.TryGetValue(fullReference, out var host) ? host : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a dead PLC does not open the breaker for healthy PLCs when using a per-call resolver.</summary>
|
||||
[Fact]
|
||||
public async Task DeadPlc_DoesNotOpenBreaker_For_HealthyPlc_With_Resolver()
|
||||
{
|
||||
@@ -59,6 +66,7 @@ public sealed class PerCallHostResolverDispatchTests
|
||||
aliveAttempts.ShouldBe(1, "decision #144 — per-PLC isolation keeps healthy PLCs serving");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty string from resolver is treated as single-host fallback.</summary>
|
||||
[Fact]
|
||||
public void Resolver_EmptyString_Treated_As_Single_Host_Fallback()
|
||||
{
|
||||
@@ -71,6 +79,7 @@ public sealed class PerCallHostResolverDispatchTests
|
||||
resolver.ResolveHost("not-in-map").ShouldBe("", "unknown refs return empty so dispatch falls back to single-host");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that without a resolver, the same host shares one resilience pipeline.</summary>
|
||||
[Fact]
|
||||
public async Task WithoutResolver_SameHost_Shares_One_Pipeline()
|
||||
{
|
||||
@@ -88,6 +97,7 @@ public sealed class PerCallHostResolverDispatchTests
|
||||
builder.CachedPipelineCount.ShouldBe(1, "single-host drivers share one pipeline");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with a resolver, different hosts get separate resilience pipelines.</summary>
|
||||
[Fact]
|
||||
public async Task WithResolver_TwoHosts_Get_Two_Pipelines()
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MemoryRecycleTests
|
||||
{
|
||||
/// <summary>Verifies that Tier C hard memory breach requests supervisor recycle.</summary>
|
||||
[Fact]
|
||||
public async Task TierC_HardBreach_RequestsSupervisorRecycle()
|
||||
{
|
||||
@@ -22,6 +23,8 @@ public sealed class MemoryRecycleTests
|
||||
supervisor.LastReason.ShouldContain("hard-breach");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Tier A and B hard memory breach never request recycle.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
@@ -36,6 +39,7 @@ public sealed class MemoryRecycleTests
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Tier C without supervisor hard breach is a no-op.</summary>
|
||||
[Fact]
|
||||
public async Task TierC_WithoutSupervisor_HardBreach_NoOp()
|
||||
{
|
||||
@@ -46,6 +50,8 @@ public sealed class MemoryRecycleTests
|
||||
requested.ShouldBeFalse("no supervisor → no recycle path; action logged only");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that soft memory breach never requests recycle at any tier.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
@@ -61,6 +67,8 @@ public sealed class MemoryRecycleTests
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-breach memory actions are no-ops.</summary>
|
||||
/// <param name="action">The non-breach memory tracking action to test.</param>
|
||||
[Theory]
|
||||
[InlineData(MemoryTrackingAction.None)]
|
||||
[InlineData(MemoryTrackingAction.Warming)]
|
||||
@@ -77,10 +85,16 @@ public sealed class MemoryRecycleTests
|
||||
|
||||
private sealed class FakeSupervisor : IDriverSupervisor
|
||||
{
|
||||
/// <summary>Gets the driver instance identifier.</summary>
|
||||
public string DriverInstanceId => "fake-tier-c";
|
||||
/// <summary>Gets the count of recycle operations.</summary>
|
||||
public int RecycleCount { get; private set; }
|
||||
/// <summary>Gets the reason from the last recycle operation.</summary>
|
||||
public string? LastReason { get; private set; }
|
||||
|
||||
/// <summary>Recycles the driver asynchronously.</summary>
|
||||
/// <param name="reason">The reason for recycling.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
RecycleCount++;
|
||||
|
||||
@@ -10,6 +10,7 @@ public sealed class MemoryTrackingTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>Verifies that warming phase returns Warming until the time window elapses.</summary>
|
||||
[Fact]
|
||||
public void WarmingUp_Returns_Warming_UntilWindowElapses()
|
||||
{
|
||||
@@ -23,6 +24,7 @@ public sealed class MemoryTrackingTests
|
||||
tracker.BaselineBytes.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that when the window elapses, baseline is captured as median and phase transitions to steady.</summary>
|
||||
[Fact]
|
||||
public void WindowElapsed_CapturesBaselineAsMedian_AndTransitionsToSteady()
|
||||
{
|
||||
@@ -38,6 +40,10 @@ public sealed class MemoryTrackingTests
|
||||
first.ShouldBe(MemoryTrackingAction.None, "150 MB is the baseline itself, well under soft threshold");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that tier constants match Decision 146 specification.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
/// <param name="expectedMultiplier">Expected growth multiplier.</param>
|
||||
/// <param name="expectedFloorMB">Expected floor in megabytes.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A, 3, 50)]
|
||||
[InlineData(DriverTier.B, 3, 100)]
|
||||
@@ -49,6 +55,7 @@ public sealed class MemoryTrackingTests
|
||||
floor.ShouldBe(expectedFloorMB * 1024 * 1024);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that soft threshold uses the maximum of multiplier and floor for small baselines.</summary>
|
||||
[Fact]
|
||||
public void SoftThreshold_UsesMax_OfMultiplierAndFloor_SmallBaseline()
|
||||
{
|
||||
@@ -57,6 +64,7 @@ public sealed class MemoryTrackingTests
|
||||
tracker.SoftThresholdBytes.ShouldBe(60L * 1024 * 1024);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that soft threshold uses the maximum of multiplier and floor for large baselines.</summary>
|
||||
[Fact]
|
||||
public void SoftThreshold_UsesMax_OfMultiplierAndFloor_LargeBaseline()
|
||||
{
|
||||
@@ -65,6 +73,7 @@ public sealed class MemoryTrackingTests
|
||||
tracker.SoftThresholdBytes.ShouldBe(600L * 1024 * 1024);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that hard threshold is twice the soft threshold.</summary>
|
||||
[Fact]
|
||||
public void HardThreshold_IsTwiceSoft()
|
||||
{
|
||||
@@ -72,6 +81,7 @@ public sealed class MemoryTrackingTests
|
||||
tracker.HardThresholdBytes.ShouldBe(tracker.SoftThresholdBytes * 2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that samples below soft threshold return None.</summary>
|
||||
[Fact]
|
||||
public void Sample_Below_Soft_Returns_None()
|
||||
{
|
||||
@@ -80,6 +90,7 @@ public sealed class MemoryTrackingTests
|
||||
tracker.Sample(200L * 1024 * 1024, T0.AddMinutes(10)).ShouldBe(MemoryTrackingAction.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that samples at soft threshold return SoftBreach.</summary>
|
||||
[Fact]
|
||||
public void Sample_AtSoft_Returns_SoftBreach()
|
||||
{
|
||||
@@ -90,6 +101,7 @@ public sealed class MemoryTrackingTests
|
||||
.ShouldBe(MemoryTrackingAction.SoftBreach);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that samples at hard threshold return HardBreach.</summary>
|
||||
[Fact]
|
||||
public void Sample_AtHard_Returns_HardBreach()
|
||||
{
|
||||
@@ -99,6 +111,7 @@ public sealed class MemoryTrackingTests
|
||||
.ShouldBe(MemoryTrackingAction.HardBreach);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that samples above hard threshold return HardBreach.</summary>
|
||||
[Fact]
|
||||
public void Sample_AboveHard_Returns_HardBreach()
|
||||
{
|
||||
|
||||
@@ -12,6 +12,8 @@ public sealed class ScheduledRecycleSchedulerTests
|
||||
private static readonly DateTime T0 = new(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Weekly = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>Verifies constructor throws for Tier A or B.</summary>
|
||||
/// <param name="tier">The driver tier to test.</param>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
@@ -22,6 +24,7 @@ public sealed class ScheduledRecycleSchedulerTests
|
||||
tier, Weekly, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
}
|
||||
|
||||
/// <summary>Verifies constructor throws for zero or negative intervals.</summary>
|
||||
[Fact]
|
||||
public void ZeroOrNegativeInterval_Throws()
|
||||
{
|
||||
@@ -32,6 +35,7 @@ public sealed class ScheduledRecycleSchedulerTests
|
||||
DriverTier.C, TimeSpan.FromSeconds(-1), T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
}
|
||||
|
||||
/// <summary>Verifies Tick before the next recycle time is a no-op.</summary>
|
||||
[Fact]
|
||||
public async Task Tick_BeforeNextRecycle_NoOp()
|
||||
{
|
||||
@@ -44,6 +48,7 @@ public sealed class ScheduledRecycleSchedulerTests
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies Tick at or after the next recycle time fires once and advances.</summary>
|
||||
[Fact]
|
||||
public async Task Tick_AtOrAfterNextRecycle_FiresOnce_AndAdvances()
|
||||
{
|
||||
@@ -57,6 +62,7 @@ public sealed class ScheduledRecycleSchedulerTests
|
||||
sch.NextRecycleUtc.ShouldBe(T0 + Weekly + Weekly);
|
||||
}
|
||||
|
||||
/// <summary>Verifies RequestRecycleNow fires immediately without advancing the schedule.</summary>
|
||||
[Fact]
|
||||
public async Task RequestRecycleNow_Fires_Immediately_WithoutAdvancingSchedule()
|
||||
{
|
||||
@@ -71,6 +77,7 @@ public sealed class ScheduledRecycleSchedulerTests
|
||||
sch.NextRecycleUtc.ShouldBe(nextBefore, "ad-hoc recycle doesn't shift the cron schedule");
|
||||
}
|
||||
|
||||
/// <summary>Verifies multiple ticks across the recycle interval each advance by one interval.</summary>
|
||||
[Fact]
|
||||
public async Task MultipleFires_AcrossTicks_AdvanceOneIntervalEach()
|
||||
{
|
||||
@@ -85,12 +92,22 @@ public sealed class ScheduledRecycleSchedulerTests
|
||||
sch.NextRecycleUtc.ShouldBe(T0 + TimeSpan.FromDays(4));
|
||||
}
|
||||
|
||||
/// <summary>Fake driver supervisor for testing.</summary>
|
||||
private sealed class FakeSupervisor : IDriverSupervisor
|
||||
{
|
||||
/// <summary>Gets the driver instance ID.</summary>
|
||||
public string DriverInstanceId => "tier-c-fake";
|
||||
|
||||
/// <summary>Gets the number of times RecycleAsync was called.</summary>
|
||||
public int RecycleCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the reason from the most recent recycle call.</summary>
|
||||
public string? LastReason { get; private set; }
|
||||
|
||||
/// <summary>Simulates a driver recycle operation.</summary>
|
||||
/// <param name="reason">The reason for the recycle.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
RecycleCount++;
|
||||
|
||||
@@ -11,6 +11,7 @@ public sealed class WedgeDetectorTests
|
||||
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Threshold = TimeSpan.FromSeconds(120);
|
||||
|
||||
/// <summary>Verifies that thresholds below 60 seconds are clamped to 60 seconds.</summary>
|
||||
[Fact]
|
||||
public void SubSixtySecondThreshold_ClampsToSixty()
|
||||
{
|
||||
@@ -18,6 +19,7 @@ public sealed class WedgeDetectorTests
|
||||
detector.Threshold.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unhealthy drivers always return NotApplicable verdict.</summary>
|
||||
[Fact]
|
||||
public void Unhealthy_Driver_AlwaysNotApplicable()
|
||||
{
|
||||
@@ -29,6 +31,7 @@ public sealed class WedgeDetectorTests
|
||||
detector.Classify(DriverState.Initializing, demand, Now).ShouldBe(WedgeVerdict.NotApplicable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an idle subscription-only driver stays Idle.</summary>
|
||||
[Fact]
|
||||
public void Idle_Subscription_Only_StaysIdle()
|
||||
{
|
||||
@@ -40,6 +43,7 @@ public sealed class WedgeDetectorTests
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Idle);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that pending work with recent progress stays Healthy.</summary>
|
||||
[Fact]
|
||||
public void PendingWork_WithRecentProgress_StaysHealthy()
|
||||
{
|
||||
@@ -49,6 +53,7 @@ public sealed class WedgeDetectorTests
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that pending work with stale progress is detected as Faulted.</summary>
|
||||
[Fact]
|
||||
public void PendingWork_WithStaleProgress_IsFaulted()
|
||||
{
|
||||
@@ -58,6 +63,7 @@ public sealed class WedgeDetectorTests
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that active monitored items without recent publishes are detected as Faulted.</summary>
|
||||
[Fact]
|
||||
public void MonitoredItems_Active_ButNoRecentPublish_IsFaulted()
|
||||
{
|
||||
@@ -70,6 +76,7 @@ public sealed class WedgeDetectorTests
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that active monitored items with fresh publishes stay Healthy.</summary>
|
||||
[Fact]
|
||||
public void MonitoredItems_Active_WithFreshPublish_StaysHealthy()
|
||||
{
|
||||
@@ -79,6 +86,7 @@ public sealed class WedgeDetectorTests
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that slow history backfill with progress stays Healthy.</summary>
|
||||
[Fact]
|
||||
public void HistoryBackfill_SlowButMakingProgress_StaysHealthy()
|
||||
{
|
||||
@@ -89,6 +97,7 @@ public sealed class WedgeDetectorTests
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-only burst stays Idle when the bulkhead is empty.</summary>
|
||||
[Fact]
|
||||
public void WriteOnlyBurst_StaysIdle_WhenBulkheadEmpty()
|
||||
{
|
||||
@@ -101,6 +110,7 @@ public sealed class WedgeDetectorTests
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Idle);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DemandSignal.HasPendingWork is true for any non-zero counter.</summary>
|
||||
[Fact]
|
||||
public void DemandSignal_HasPendingWork_TrueForAnyNonZeroCounter()
|
||||
{
|
||||
|
||||
@@ -16,6 +16,8 @@ public sealed class DependencyGraphTests
|
||||
private static IReadOnlySet<string> Set(params string[] items) =>
|
||||
new HashSet<string>(items, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Verifies that an empty dependency graph produces an empty topological sort and no cycles.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Empty_graph_produces_empty_sort_and_no_cycles()
|
||||
{
|
||||
@@ -24,6 +26,8 @@ public sealed class DependencyGraphTests
|
||||
g.DetectCycles().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a single node with no dependencies sorts correctly.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Single_node_with_no_deps()
|
||||
{
|
||||
@@ -33,6 +37,8 @@ public sealed class DependencyGraphTests
|
||||
g.DetectCycles().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that topological sort places dependencies before their dependents.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Topological_order_places_dependencies_before_dependents()
|
||||
{
|
||||
@@ -47,6 +53,8 @@ public sealed class DependencyGraphTests
|
||||
idx["B"].ShouldBeLessThan(idx["C"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a self-referencing node is detected as a cycle.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Self_loop_detected_as_cycle()
|
||||
{
|
||||
@@ -57,6 +65,8 @@ public sealed class DependencyGraphTests
|
||||
cycles[0].ShouldContain("A");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a two-node cycle is detected.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Two_node_cycle_detected()
|
||||
{
|
||||
@@ -68,6 +78,8 @@ public sealed class DependencyGraphTests
|
||||
cycles[0].Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a three-node cycle is detected.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Three_node_cycle_detected()
|
||||
{
|
||||
@@ -80,6 +92,8 @@ public sealed class DependencyGraphTests
|
||||
cycles[0].Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multiple disjoint cycles are all reported.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Multiple_disjoint_cycles_all_reported()
|
||||
{
|
||||
@@ -98,6 +112,8 @@ public sealed class DependencyGraphTests
|
||||
cycles.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that topological sort throws DependencyCycleException when a cycle is present.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Topological_sort_throws_DependencyCycleException_on_cycle()
|
||||
{
|
||||
@@ -108,6 +124,8 @@ public sealed class DependencyGraphTests
|
||||
.Cycles.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DirectDependents returns only direct dependents, not transitive ones.</summary>
|
||||
|
||||
[Fact]
|
||||
public void DirectDependents_returns_direct_only()
|
||||
{
|
||||
@@ -119,6 +137,8 @@ public sealed class DependencyGraphTests
|
||||
g.DirectDependents("C").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that TransitiveDependentsInOrder returns the topological closure of dependents.</summary>
|
||||
|
||||
[Fact]
|
||||
public void TransitiveDependentsInOrder_returns_topological_closure()
|
||||
{
|
||||
@@ -130,6 +150,8 @@ public sealed class DependencyGraphTests
|
||||
closure.ShouldBe(new[] { "B", "C", "D" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that readding a node overwrites its prior dependencies.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Readding_a_node_overwrites_prior_dependencies()
|
||||
{
|
||||
@@ -143,6 +165,8 @@ public sealed class DependencyGraphTests
|
||||
g.DirectDependents("A").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that leaf dependencies not registered as nodes are treated as implicit upstream nodes.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Leaf_dependencies_not_registered_as_nodes_are_treated_as_implicit()
|
||||
{
|
||||
@@ -155,6 +179,8 @@ public sealed class DependencyGraphTests
|
||||
|
||||
// ----- Core.VirtualTags-013: DependencyCycleException message must not present SCC as edge path -----
|
||||
|
||||
/// <summary>Verifies that DependencyCycleException message describes cycle members and not a fabricated edge path.</summary>
|
||||
|
||||
[Fact]
|
||||
public void DependencyCycleException_message_describes_cycle_members_not_a_fabricated_edge_path()
|
||||
{
|
||||
@@ -183,6 +209,8 @@ public sealed class DependencyGraphTests
|
||||
|
||||
// ----- Core.VirtualTags-009: empty-set allocation on miss -----
|
||||
|
||||
/// <summary>Verifies that querying direct dependencies for an unregistered node returns a shared empty set.</summary>
|
||||
|
||||
[Fact]
|
||||
public void DirectDependencies_miss_returns_shared_empty_set_instance()
|
||||
{
|
||||
@@ -198,6 +226,8 @@ public sealed class DependencyGraphTests
|
||||
ReferenceEquals(a, b).ShouldBeTrue("miss path must return the shared empty-set instance");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that querying direct dependents for an unregistered node returns a shared empty set.</summary>
|
||||
|
||||
[Fact]
|
||||
public void DirectDependents_miss_returns_shared_empty_set_instance()
|
||||
{
|
||||
@@ -212,6 +242,8 @@ public sealed class DependencyGraphTests
|
||||
ReferenceEquals(a, b).ShouldBeTrue("miss path must return the shared empty-set instance");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that deep dependency graphs do not cause stack overflow.</summary>
|
||||
|
||||
[Fact]
|
||||
public void Deep_graph_no_stack_overflow()
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user