chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Creates two throwaway DB users — one in <c>OtOpcUaNode</c>, one in <c>OtOpcUaAdmin</c> —
|
||||
/// and verifies the grants/denies from the <c>AuthorizationGrants</c> migration.
|
||||
/// </summary>
|
||||
[Trait("Category", "Authorization")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class AuthorizationTests
|
||||
{
|
||||
private readonly SchemaComplianceFixture _fixture;
|
||||
|
||||
public AuthorizationTests(SchemaComplianceFixture fixture) => _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public void Node_role_can_execute_GetCurrentGenerationForCluster_but_not_PublishGeneration()
|
||||
{
|
||||
var (user, password) = CreateUserInRole(_fixture, "Node");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = OpenAs(user, password);
|
||||
|
||||
Should.Throw<SqlException>(() =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_PublishGeneration @ClusterId='x', @DraftGenerationId=1";
|
||||
cmd.ExecuteNonQuery();
|
||||
}).Message.ShouldContain("permission", Case.Insensitive);
|
||||
|
||||
// Calling a granted proc authenticates; the proc itself will RAISERROR with Unauthorized
|
||||
// because our test principal isn't bound to any node — that's expected.
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId='n', @ClusterId='c'";
|
||||
cmd.ExecuteNonQuery();
|
||||
});
|
||||
ex.Message.ShouldContain("Unauthorized");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DropUser(_fixture, user);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Node_role_cannot_SELECT_from_tables_directly()
|
||||
{
|
||||
var (user, password) = CreateUserInRole(_fixture, "Node");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = OpenAs(user, password);
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM dbo.ConfigGeneration";
|
||||
cmd.ExecuteScalar();
|
||||
});
|
||||
ex.Message.ShouldContain("permission", Case.Insensitive);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DropUser(_fixture, user);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admin_role_can_execute_PublishGeneration()
|
||||
{
|
||||
var (user, password) = CreateUserInRole(_fixture, "Admin");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = OpenAs(user, password);
|
||||
// Calling the proc is permitted; content-level errors (missing draft) are OK — they
|
||||
// prove the grant succeeded (we got past the permission check into the proc body).
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_PublishGeneration @ClusterId='no-such-cluster', @DraftGenerationId=9999";
|
||||
cmd.ExecuteNonQuery();
|
||||
});
|
||||
ex.Message.ShouldNotContain("permission", Case.Insensitive);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DropUser(_fixture, user);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a SQL login + DB user in the given role and returns its credentials.</summary>
|
||||
private static (string User, string Password) CreateUserInRole(SchemaComplianceFixture fx, string role)
|
||||
{
|
||||
var user = $"tst_{role.ToLower()}_{Guid.NewGuid():N}"[..24];
|
||||
const string password = "TestUser_2026!";
|
||||
var dbRole = role == "Node" ? "OtOpcUaNode" : "OtOpcUaAdmin";
|
||||
|
||||
// Create the login in master, the user in the test DB, and add it to the role.
|
||||
using (var conn = new SqlConnection(
|
||||
new SqlConnectionStringBuilder(fx.ConnectionString) { InitialCatalog = "master" }.ConnectionString))
|
||||
{
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"CREATE LOGIN [{user}] WITH PASSWORD = '{password}', CHECK_POLICY = OFF;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var conn = fx.OpenConnection())
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
CREATE USER [{user}] FOR LOGIN [{user}];
|
||||
ALTER ROLE {dbRole} ADD MEMBER [{user}];";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
return (user, password);
|
||||
}
|
||||
|
||||
private static void DropUser(SchemaComplianceFixture fx, string user)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var dbConn = fx.OpenConnection();
|
||||
using var cmd1 = dbConn.CreateCommand();
|
||||
cmd1.CommandText = $"IF DATABASE_PRINCIPAL_ID('{user}') IS NOT NULL DROP USER [{user}];";
|
||||
cmd1.ExecuteNonQuery();
|
||||
}
|
||||
catch { /* swallow — fixture disposes the DB anyway */ }
|
||||
|
||||
try
|
||||
{
|
||||
using var master = new SqlConnection(
|
||||
new SqlConnectionStringBuilder(fx.ConnectionString) { InitialCatalog = "master" }.ConnectionString);
|
||||
master.Open();
|
||||
using var cmd = master.CreateCommand();
|
||||
cmd.CommandText = $"IF SUSER_ID('{user}') IS NOT NULL DROP LOGIN [{user}];";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private SqlConnection OpenAs(string user, string password)
|
||||
{
|
||||
var cs = new SqlConnectionStringBuilder(_fixture.ConnectionString)
|
||||
{
|
||||
UserID = user,
|
||||
Password = password,
|
||||
IntegratedSecurity = false,
|
||||
}.ConnectionString;
|
||||
|
||||
var conn = new SqlConnection(cs);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftValidatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("valid-name", true)]
|
||||
[InlineData("line-01", true)]
|
||||
[InlineData("_default", true)]
|
||||
[InlineData("UPPER", false)]
|
||||
[InlineData("with space", false)]
|
||||
[InlineData("", false)]
|
||||
public void UnsSegment_rule_accepts_lowercase_or_default_only(string name, bool shouldPass)
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Equipment =
|
||||
[
|
||||
new Equipment
|
||||
{
|
||||
EquipmentUuid = uuid,
|
||||
EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
|
||||
Name = name,
|
||||
DriverInstanceId = "d",
|
||||
UnsLineId = "line-a",
|
||||
MachineCode = "m",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var errors = DraftValidator.Validate(draft);
|
||||
var hasUnsError = errors.Any(e => e.Code == "UnsSegmentInvalid");
|
||||
hasUnsError.ShouldBe(!shouldPass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cross_cluster_namespace_binding_is_rejected()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c-A",
|
||||
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
||||
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }],
|
||||
};
|
||||
|
||||
var errors = DraftValidator.Validate(draft);
|
||||
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_cluster_namespace_binding_is_accepted()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c-A",
|
||||
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-A", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
||||
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "BadCrossClusterNamespaceBinding");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquipmentUuid_change_across_generations_is_rejected()
|
||||
{
|
||||
var oldUuid = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var newUuid = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
var eid = DraftValidator.DeriveEquipmentId(oldUuid);
|
||||
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 2, ClusterId = "c",
|
||||
Equipment = [new Equipment { EquipmentUuid = newUuid, EquipmentId = eid, Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }],
|
||||
PriorEquipment = [new Equipment { EquipmentUuid = oldUuid, EquipmentId = eid, Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentUuidImmutable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZTag_reserved_by_different_uuid_is_rejected()
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var otherUuid = Guid.NewGuid();
|
||||
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m", ZTag = "ZT-001" }],
|
||||
ActiveReservations = [new ExternalIdReservation { Kind = ReservationKind.ZTag, Value = "ZT-001", EquipmentUuid = otherUuid, ClusterId = "c", FirstPublishedBy = "t" }],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "BadDuplicateExternalIdentifier");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquipmentId_that_does_not_match_derivation_is_rejected()
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-operator-typed", Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Galaxy_driver_in_Equipment_namespace_is_rejected()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
||||
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c", NamespaceId = "ns-1", Name = "drv", DriverType = "Galaxy", DriverConfig = "{}" }],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Draft_with_three_violations_surfaces_all_three()
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c-A",
|
||||
Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }],
|
||||
DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "Galaxy", DriverConfig = "{}" }],
|
||||
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-wrong", Name = "BAD NAME", DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "m" }],
|
||||
};
|
||||
|
||||
var errors = DraftValidator.Validate(draft);
|
||||
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
|
||||
errors.ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
|
||||
errors.ShouldContain(e => e.Code == "EquipmentIdNotDerived");
|
||||
errors.ShouldContain(e => e.Code == "UnsSegmentInvalid");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Phase 6.3 task #148 part 2 — ValidateClusterTopology
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, RedundancyMode.None, 1, 0)] // single-node standalone — ok
|
||||
[InlineData(2, RedundancyMode.Warm, 2, 0)] // 2-node warm — ok
|
||||
[InlineData(2, RedundancyMode.Hot, 2, 0)] // 2-node hot — ok
|
||||
[InlineData(1, RedundancyMode.Warm, 1, 1)] // declared mismatch — should flag
|
||||
[InlineData(2, RedundancyMode.None, 2, 1)] // None with 2 nodes — should flag
|
||||
public void ValidateClusterTopology_checks_declared_pair(
|
||||
byte nodeCount, RedundancyMode mode, int enabledNodes, int expectedDeclaredErrors)
|
||||
{
|
||||
var cluster = BuildCluster(nodeCount: nodeCount, mode: mode);
|
||||
var nodes = Enumerable.Range(0, enabledNodes)
|
||||
.Select(i => BuildNode($"n-{i}", enabled: true, role: i == 0 ? RedundancyRole.Primary : RedundancyRole.Secondary))
|
||||
.ToList();
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.Count(e => e.Code == "ClusterRedundancyModeInvalid").ShouldBe(expectedDeclaredErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_flags_disabled_node_mismatch()
|
||||
{
|
||||
// Declared 2 + Hot, but one node disabled — runtime would boot InvalidTopology.
|
||||
var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot);
|
||||
var nodes = new[]
|
||||
{
|
||||
BuildNode("primary", enabled: true, role: RedundancyRole.Primary),
|
||||
BuildNode("backup", enabled: false, role: RedundancyRole.Secondary),
|
||||
};
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.ShouldContain(e => e.Code == "ClusterEnabledNodeCountMismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_flags_multiple_Primary()
|
||||
{
|
||||
var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot);
|
||||
var nodes = new[]
|
||||
{
|
||||
BuildNode("a", enabled: true, role: RedundancyRole.Primary),
|
||||
BuildNode("b", enabled: true, role: RedundancyRole.Primary),
|
||||
};
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.ShouldContain(e => e.Code == "ClusterMultiplePrimary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_returns_no_errors_on_valid_standalone()
|
||||
{
|
||||
var cluster = BuildCluster(nodeCount: 1, mode: RedundancyMode.None);
|
||||
var nodes = new[] { BuildNode("only", enabled: true, role: RedundancyRole.Primary) };
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static ServerCluster BuildCluster(byte nodeCount, RedundancyMode mode) => new()
|
||||
{
|
||||
ClusterId = "c-test",
|
||||
Name = "Test",
|
||||
Enterprise = "zb",
|
||||
Site = "dev",
|
||||
NodeCount = nodeCount,
|
||||
RedundancyMode = mode,
|
||||
Enabled = true,
|
||||
CreatedBy = "t",
|
||||
};
|
||||
|
||||
private static ClusterNode BuildNode(string id, bool enabled, RedundancyRole role) => new()
|
||||
{
|
||||
NodeId = id,
|
||||
ClusterId = "c-test",
|
||||
RedundancyRole = role,
|
||||
Host = "localhost",
|
||||
OpcUaPort = 4840,
|
||||
DashboardPort = 5001,
|
||||
ApplicationUri = $"urn:{id}",
|
||||
ServiceLevelBase = 200,
|
||||
Enabled = enabled,
|
||||
CreatedBy = "t",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end round-trip through the DB for the <see cref="DriverHostStatus"/> entity
|
||||
/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId,
|
||||
/// HostName), string-backed <c>DriverHostState</c> conversion, and the two indexes the
|
||||
/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc).
|
||||
/// </summary>
|
||||
[Trait("Category", "SchemaCompliance")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
|
||||
// Same HostName + DriverInstanceId across two different server nodes — classic 2-node
|
||||
// redundancy case. Both rows must be insertable because each server node owns its own
|
||||
// runtime view of the shared host.
|
||||
var now = DateTime.UtcNow;
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
});
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Stopped,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
Detail = "secondary hasn't taken over yet",
|
||||
});
|
||||
// Same server node + host, different driver instance — second driver doesn't clobber.
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var rows = await ctx.DriverHostStatuses.AsNoTracking()
|
||||
.Where(r => r.HostName == "GRPlatform").ToListAsync();
|
||||
|
||||
rows.Count.ShouldBe(3);
|
||||
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1");
|
||||
rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet");
|
||||
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_pattern_for_same_key_updates_in_place()
|
||||
{
|
||||
// The publisher hosted service (follow-up PR) upserts on every transition +
|
||||
// heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update
|
||||
// keyed on the composite PK. If the composite key ever changes, this test breaks
|
||||
// loudly so the publisher gets a synchronized update.
|
||||
await using var ctx = NewContext();
|
||||
var t0 = DateTime.UtcNow;
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = t0, LastSeenUtc = t0,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var t1 = t0.AddSeconds(30);
|
||||
await using (var ctx2 = NewContext())
|
||||
{
|
||||
var existing = await ctx2.DriverHostStatuses.SingleAsync(r =>
|
||||
r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host");
|
||||
existing.State = DriverHostState.Faulted;
|
||||
existing.StateChangedUtc = t1;
|
||||
existing.LastSeenUtc = t1;
|
||||
existing.Detail = "transport reset by peer";
|
||||
await ctx2.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var ctx3 = NewContext();
|
||||
var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r =>
|
||||
r.NodeId == "upsert-node" && r.HostName == "upsert-host");
|
||||
final.State.ShouldBe(DriverHostState.Faulted);
|
||||
final.Detail.ShouldBe("transport reset by peer");
|
||||
// Only one row — a naive "always insert" would have created a duplicate PK and thrown.
|
||||
(await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enum_persists_as_string_not_int()
|
||||
{
|
||||
// Fluent config sets HasConversion<string>() on State — the DB stores 'Running' /
|
||||
// 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw
|
||||
// string back via ADO; if someone drops the conversion the column will contain '1'
|
||||
// / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table
|
||||
// directly should see readable state names, not enum ordinals.
|
||||
await using var ctx = NewContext();
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host",
|
||||
State = DriverHostState.Faulted,
|
||||
StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await using var conn = fixture.OpenConnection();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'";
|
||||
var rawValue = (string?)await cmd.ExecuteScalarAsync();
|
||||
rawValue.ShouldBe("Faulted");
|
||||
}
|
||||
|
||||
private OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(fixture.ConnectionString)
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GenerationApplierTests
|
||||
{
|
||||
private static DraftSnapshot SnapshotWith(
|
||||
IReadOnlyList<DriverInstance>? drivers = null,
|
||||
IReadOnlyList<Equipment>? equipment = null,
|
||||
IReadOnlyList<Tag>? tags = null) => new()
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
DriverInstances = drivers ?? [],
|
||||
Equipment = equipment ?? [],
|
||||
Tags = tags ?? [],
|
||||
};
|
||||
|
||||
private static DriverInstance Driver(string id) =>
|
||||
new() { DriverInstanceId = id, ClusterId = "c", NamespaceId = "ns", Name = id, DriverType = "ModbusTcp", DriverConfig = "{}" };
|
||||
|
||||
private static Equipment Eq(string id, Guid uuid) =>
|
||||
new() { EquipmentUuid = uuid, EquipmentId = id, DriverInstanceId = "d", UnsLineId = "line-a", Name = id, MachineCode = id };
|
||||
|
||||
private static Tag Tag(string id, string name) =>
|
||||
new() { TagId = id, DriverInstanceId = "d", Name = name, FolderPath = "/a", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" };
|
||||
|
||||
[Fact]
|
||||
public void Diff_from_empty_to_one_driver_five_equipment_fifty_tags_is_all_Added()
|
||||
{
|
||||
var uuid = (int i) => Guid.Parse($"00000000-0000-0000-0000-{i:000000000000}");
|
||||
var equipment = Enumerable.Range(1, 5).Select(i => Eq($"eq-{i}", uuid(i))).ToList();
|
||||
var tags = Enumerable.Range(1, 50).Select(i => Tag($"tag-{i}", $"T{i}")).ToList();
|
||||
|
||||
var diff = GenerationDiffer.Compute(from: null,
|
||||
to: SnapshotWith(drivers: [Driver("d-1")], equipment: equipment, tags: tags));
|
||||
|
||||
diff.Drivers.Count.ShouldBe(1);
|
||||
diff.Drivers.ShouldAllBe(c => c.Kind == ChangeKind.Added);
|
||||
diff.Equipment.Count.ShouldBe(5);
|
||||
diff.Equipment.ShouldAllBe(c => c.Kind == ChangeKind.Added);
|
||||
diff.Tags.Count.ShouldBe(50);
|
||||
diff.Tags.ShouldAllBe(c => c.Kind == ChangeKind.Added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_flags_single_tag_name_change_as_Modified_only_for_that_tag()
|
||||
{
|
||||
var before = SnapshotWith(tags: [Tag("tag-1", "Old"), Tag("tag-2", "Keep")]);
|
||||
var after = SnapshotWith(tags: [Tag("tag-1", "New"), Tag("tag-2", "Keep")]);
|
||||
|
||||
var diff = GenerationDiffer.Compute(before, after);
|
||||
|
||||
diff.Tags.Count.ShouldBe(1);
|
||||
diff.Tags[0].Kind.ShouldBe(ChangeKind.Modified);
|
||||
diff.Tags[0].LogicalId.ShouldBe("tag-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_flags_Removed_equipment_and_its_tags()
|
||||
{
|
||||
var uuid1 = Guid.NewGuid();
|
||||
var before = SnapshotWith(
|
||||
equipment: [Eq("eq-1", uuid1), Eq("eq-2", Guid.NewGuid())],
|
||||
tags: [Tag("tag-1", "A"), Tag("tag-2", "B")]);
|
||||
var after = SnapshotWith(
|
||||
equipment: [Eq("eq-2", before.Equipment[1].EquipmentUuid)],
|
||||
tags: [Tag("tag-2", "B")]);
|
||||
|
||||
var diff = GenerationDiffer.Compute(before, after);
|
||||
|
||||
diff.Equipment.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "eq-1");
|
||||
diff.Tags.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "tag-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_dispatches_callbacks_in_dependency_order_and_survives_idempotent_retry()
|
||||
{
|
||||
var callLog = new List<string>();
|
||||
var applier = new GenerationApplier(new ApplyCallbacks
|
||||
{
|
||||
OnDriver = (c, _) => { callLog.Add($"drv:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; },
|
||||
OnEquipment = (c, _) => { callLog.Add($"eq:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; },
|
||||
OnTag = (c, _) => { callLog.Add($"tag:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; },
|
||||
});
|
||||
|
||||
var to = SnapshotWith(
|
||||
drivers: [Driver("d-1")],
|
||||
equipment: [Eq("eq-1", Guid.NewGuid())],
|
||||
tags: [Tag("tag-1", "A")]);
|
||||
|
||||
var result1 = await applier.ApplyAsync(from: null, to, CancellationToken.None);
|
||||
result1.Succeeded.ShouldBeTrue();
|
||||
|
||||
// Driver Added must come before Equipment Added must come before Tag Added
|
||||
var drvIdx = callLog.FindIndex(s => s.StartsWith("drv:Added"));
|
||||
var eqIdx = callLog.FindIndex(s => s.StartsWith("eq:Added"));
|
||||
var tagIdx = callLog.FindIndex(s => s.StartsWith("tag:Added"));
|
||||
drvIdx.ShouldBeLessThan(eqIdx);
|
||||
eqIdx.ShouldBeLessThan(tagIdx);
|
||||
|
||||
// Idempotent retry: re-applying the same diff must not blow up
|
||||
var countBefore = callLog.Count;
|
||||
var result2 = await applier.ApplyAsync(from: null, to, CancellationToken.None);
|
||||
result2.Succeeded.ShouldBeTrue();
|
||||
callLog.Count.ShouldBe(countBefore * 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_collects_errors_from_failing_callback_without_aborting()
|
||||
{
|
||||
var applier = new GenerationApplier(new ApplyCallbacks
|
||||
{
|
||||
OnTag = (c, _) =>
|
||||
c.LogicalId == "tag-bad"
|
||||
? throw new InvalidOperationException("simulated")
|
||||
: Task.CompletedTask,
|
||||
});
|
||||
|
||||
var to = SnapshotWith(tags: [Tag("tag-ok", "A"), Tag("tag-bad", "B")]);
|
||||
var result = await applier.ApplyAsync(from: null, to, CancellationToken.None);
|
||||
|
||||
result.Succeeded.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("tag-bad") && e.Contains("simulated"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GenerationSealedCacheTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-sealed-{Guid.NewGuid():N}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_root)) return;
|
||||
// Remove ReadOnly attribute first so Directory.Delete can clean sealed files.
|
||||
foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
|
||||
File.SetAttributes(f, FileAttributes.Normal);
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
|
||||
private GenerationSnapshot MakeSnapshot(string clusterId, long generationId, string payload = "{\"sample\":true}") =>
|
||||
new()
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
GenerationId = generationId,
|
||||
CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = payload,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task FirstBoot_NoSnapshot_ReadThrows()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SealThenRead_RoundTrips()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var snapshot = MakeSnapshot("cluster-a", 42, "{\"hello\":\"world\"}");
|
||||
|
||||
await cache.SealAsync(snapshot);
|
||||
|
||||
var read = await cache.ReadCurrentAsync("cluster-a");
|
||||
read.GenerationId.ShouldBe(42);
|
||||
read.ClusterId.ShouldBe("cluster-a");
|
||||
read.PayloadJson.ShouldBe("{\"hello\":\"world\"}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SealedFile_IsReadOnly_OnDisk()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 5));
|
||||
|
||||
var sealedPath = Path.Combine(_root, "cluster-a", "5.db");
|
||||
File.Exists(sealedPath).ShouldBeTrue();
|
||||
var attrs = File.GetAttributes(sealedPath);
|
||||
attrs.HasFlag(FileAttributes.ReadOnly).ShouldBeTrue("sealed file must be read-only");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SealingTwoGenerations_PointerAdvances_ToLatest()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 1));
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 2));
|
||||
|
||||
cache.TryGetCurrentGenerationId("cluster-a").ShouldBe(2);
|
||||
var read = await cache.ReadCurrentAsync("cluster-a");
|
||||
read.GenerationId.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PriorGenerationFile_Survives_AfterNewSeal()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 1));
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 2));
|
||||
|
||||
File.Exists(Path.Combine(_root, "cluster-a", "1.db")).ShouldBeTrue(
|
||||
"prior generations preserved for audit; pruning is separate");
|
||||
File.Exists(Path.Combine(_root, "cluster-a", "2.db")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorruptSealedFile_ReadFailsClosed()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 7));
|
||||
|
||||
// Corrupt the sealed file: clear read-only, truncate, leave pointer intact.
|
||||
var sealedPath = Path.Combine(_root, "cluster-a", "7.db");
|
||||
File.SetAttributes(sealedPath, FileAttributes.Normal);
|
||||
File.WriteAllBytes(sealedPath, [0x00, 0x01, 0x02]);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingSealedFile_ReadFailsClosed()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 3));
|
||||
|
||||
// Delete the sealed file but leave the pointer — corruption scenario.
|
||||
var sealedPath = Path.Combine(_root, "cluster-a", "3.db");
|
||||
File.SetAttributes(sealedPath, FileAttributes.Normal);
|
||||
File.Delete(sealedPath);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorruptPointerFile_ReadFailsClosed()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 9));
|
||||
|
||||
var pointerPath = Path.Combine(_root, "cluster-a", "CURRENT");
|
||||
File.WriteAllText(pointerPath, "not-a-number");
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SealSameGenerationTwice_IsIdempotent()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 11));
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 11, "{\"v\":2}"));
|
||||
|
||||
var read = await cache.ReadCurrentAsync("cluster-a");
|
||||
read.PayloadJson.ShouldBe("{\"sample\":true}", "sealed file is immutable; second seal no-ops");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IndependentClusters_DoNotInterfere()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(MakeSnapshot("cluster-a", 1));
|
||||
await cache.SealAsync(MakeSnapshot("cluster-b", 10));
|
||||
|
||||
(await cache.ReadCurrentAsync("cluster-a")).GenerationId.ShouldBe(1);
|
||||
(await cache.ReadCurrentAsync("cluster-b")).GenerationId.ShouldBe(10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
|
||||
public LdapGroupRoleMappingServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private LdapGroupRoleMapping Make(string group, AdminRole role, string? clusterId = null, bool? isSystemWide = null) =>
|
||||
new()
|
||||
{
|
||||
LdapGroup = group,
|
||||
Role = role,
|
||||
ClusterId = clusterId,
|
||||
IsSystemWide = isSystemWide ?? (clusterId is null),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Create_SetsId_AndCreatedAtUtc()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=fleet,dc=x", AdminRole.FleetAdmin);
|
||||
|
||||
var saved = await svc.CreateAsync(row, CancellationToken.None);
|
||||
|
||||
saved.Id.ShouldNotBe(Guid.Empty);
|
||||
saved.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Rejects_EmptyLdapGroup()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("", AdminRole.FleetAdmin);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Rejects_SystemWide_With_ClusterId()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: "c1", isSystemWide: true);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Rejects_NonSystemWide_WithoutClusterId()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: null, isSystemWide: false);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByGroups_Returns_MatchingGrants_Only()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=editor,dc=x", AdminRole.ConfigEditor), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=viewer,dc=x", AdminRole.ConfigViewer), CancellationToken.None);
|
||||
|
||||
var results = await svc.GetByGroupsAsync(
|
||||
["cn=fleet,dc=x", "cn=viewer,dc=x"], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results.Select(r => r.Role).ShouldBe([AdminRole.FleetAdmin, AdminRole.ConfigViewer], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByGroups_Empty_Input_ReturnsEmpty()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
|
||||
var results = await svc.GetByGroupsAsync([], CancellationToken.None);
|
||||
|
||||
results.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAll_Orders_ByGroupThenCluster()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=b,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c2", isSystemWide: false), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c1", isSystemWide: false), CancellationToken.None);
|
||||
|
||||
var results = await svc.ListAllAsync(CancellationToken.None);
|
||||
|
||||
results[0].LdapGroup.ShouldBe("cn=a,dc=x");
|
||||
results[0].ClusterId.ShouldBe("c1");
|
||||
results[1].ClusterId.ShouldBe("c2");
|
||||
results[2].LdapGroup.ShouldBe("cn=b,dc=x");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Removes_Matching_Row()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var saved = await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
|
||||
await svc.DeleteAsync(saved.Id, CancellationToken.None);
|
||||
|
||||
var after = await svc.ListAllAsync(CancellationToken.None);
|
||||
after.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Unknown_Id_IsNoOp()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
|
||||
await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
// no exception
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-cache-test-{Guid.NewGuid():N}.db");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath)) File.Delete(_dbPath);
|
||||
}
|
||||
|
||||
private GenerationSnapshot Snapshot(string cluster, long gen) => new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
GenerationId = gen,
|
||||
CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = $"{{\"g\":{gen}}}",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Roundtrip_preserves_payload()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
var put = Snapshot("c-1", 42);
|
||||
await cache.PutAsync(put);
|
||||
|
||||
var got = await cache.GetMostRecentAsync("c-1");
|
||||
got.ShouldNotBeNull();
|
||||
got!.GenerationId.ShouldBe(42);
|
||||
got.PayloadJson.ShouldBe(put.PayloadJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMostRecent_returns_latest_when_multiple_generations_present()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
foreach (var g in new long[] { 10, 20, 15 })
|
||||
await cache.PutAsync(Snapshot("c-1", g));
|
||||
|
||||
var got = await cache.GetMostRecentAsync("c-1");
|
||||
got!.GenerationId.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMostRecent_returns_null_for_unknown_cluster()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
(await cache.GetMostRecentAsync("ghost")).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prune_keeps_latest_N_and_drops_older()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
for (long g = 1; g <= 15; g++)
|
||||
await cache.PutAsync(Snapshot("c-1", g));
|
||||
|
||||
await cache.PruneOldGenerationsAsync("c-1", keepLatest: 10);
|
||||
|
||||
(await cache.GetMostRecentAsync("c-1"))!.GenerationId.ShouldBe(15);
|
||||
|
||||
// Drop them one by one and count — should be exactly 10 remaining
|
||||
var count = 0;
|
||||
while (await cache.GetMostRecentAsync("c-1") is not null)
|
||||
{
|
||||
count++;
|
||||
await cache.PruneOldGenerationsAsync("c-1", keepLatest: Math.Max(0, 10 - count));
|
||||
if (count > 20) break; // safety
|
||||
}
|
||||
count.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_same_cluster_generation_twice_replaces_not_duplicates()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
var first = Snapshot("c-1", 1);
|
||||
first.PayloadJson = "{\"v\":1}";
|
||||
await cache.PutAsync(first);
|
||||
|
||||
var second = Snapshot("c-1", 1);
|
||||
second.PayloadJson = "{\"v\":2}";
|
||||
await cache.PutAsync(second);
|
||||
|
||||
(await cache.GetMostRecentAsync("c-1"))!.PayloadJson.ShouldBe("{\"v\":2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException()
|
||||
{
|
||||
// Write a file large enough to look like a LiteDB page but with garbage contents so page
|
||||
// deserialization fails on the first read probe.
|
||||
File.WriteAllBytes(_dbPath, new byte[8192]);
|
||||
Array.Fill<byte>(File.ReadAllBytes(_dbPath), 0xAB);
|
||||
using (var fs = File.OpenWrite(_dbPath))
|
||||
{
|
||||
fs.Write(new byte[8192].Select(_ => (byte)0xAB).ToArray());
|
||||
}
|
||||
|
||||
Should.Throw<LocalConfigCacheCorruptException>(() => new LiteDbConfigCache(_dbPath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Phase 7 Stream E entities (<see cref="Script"/>, <see cref="VirtualTag"/>,
|
||||
/// <see cref="ScriptedAlarm"/>, <see cref="ScriptedAlarmState"/>) register correctly in
|
||||
/// the EF model, map to the expected tables/columns/indexes, and carry the check constraints
|
||||
/// the plan decisions call for. Introspection only — no SQL Server required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Phase7ScriptingEntitiesTests
|
||||
{
|
||||
private static OtOpcUaConfigDbContext BuildCtx()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer("Server=(local);Database=dummy;Integrated Security=true") // not connected
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
private static Microsoft.EntityFrameworkCore.Metadata.IModel DesignModel(OtOpcUaConfigDbContext ctx)
|
||||
=> ctx.GetService<IDesignTimeModel>().Model;
|
||||
|
||||
[Fact]
|
||||
public void Script_entity_registered_with_expected_table_and_columns()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
|
||||
|
||||
entity.GetTableName().ShouldBe("Script");
|
||||
entity.FindProperty(nameof(Script.ScriptRowId)).ShouldNotBeNull();
|
||||
entity.FindProperty(nameof(Script.ScriptId)).ShouldNotBeNull()
|
||||
.GetMaxLength().ShouldBe(64);
|
||||
entity.FindProperty(nameof(Script.SourceCode)).ShouldNotBeNull()
|
||||
.GetColumnType().ShouldBe("nvarchar(max)");
|
||||
entity.FindProperty(nameof(Script.SourceHash)).ShouldNotBeNull()
|
||||
.GetMaxLength().ShouldBe(64);
|
||||
entity.FindProperty(nameof(Script.Language)).ShouldNotBeNull()
|
||||
.GetMaxLength().ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Script_has_unique_logical_id_per_generation()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
|
||||
entity.GetIndexes().ShouldContain(
|
||||
i => i.IsUnique && i.GetDatabaseName() == "UX_Script_Generation_LogicalId");
|
||||
entity.GetIndexes().ShouldContain(
|
||||
i => i.GetDatabaseName() == "IX_Script_Generation_SourceHash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualTag_entity_registered_with_trigger_check_constraint()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = DesignModel(ctx).FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
||||
entity.GetTableName().ShouldBe("VirtualTag");
|
||||
|
||||
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
||||
checks.ShouldContain("CK_VirtualTag_Trigger_AtLeastOne");
|
||||
checks.ShouldContain("CK_VirtualTag_TimerInterval_Min");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualTag_enforces_unique_name_per_Equipment()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
||||
entity.GetIndexes().ShouldContain(
|
||||
i => i.IsUnique && i.GetDatabaseName() == "UX_VirtualTag_Generation_EquipmentPath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualTag_has_ChangeTriggered_and_Historize_flags()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
||||
entity.FindProperty(nameof(VirtualTag.ChangeTriggered)).ShouldNotBeNull()
|
||||
.ClrType.ShouldBe(typeof(bool));
|
||||
entity.FindProperty(nameof(VirtualTag.Historize)).ShouldNotBeNull()
|
||||
.ClrType.ShouldBe(typeof(bool));
|
||||
entity.FindProperty(nameof(VirtualTag.TimerIntervalMs)).ShouldNotBeNull()
|
||||
.ClrType.ShouldBe(typeof(int?));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_entity_registered_with_severity_and_type_checks()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarm)).ShouldNotBeNull();
|
||||
entity.GetTableName().ShouldBe("ScriptedAlarm");
|
||||
|
||||
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
||||
checks.ShouldContain("CK_ScriptedAlarm_Severity_Range");
|
||||
checks.ShouldContain("CK_ScriptedAlarm_AlarmType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_has_HistorizeToAveva_default_true_per_plan_decision_15()
|
||||
{
|
||||
// Defaults live on the CLR default assignment — verify the initializer.
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "a1",
|
||||
EquipmentId = "eq1",
|
||||
Name = "n",
|
||||
AlarmType = "LimitAlarm",
|
||||
MessageTemplate = "m",
|
||||
PredicateScriptId = "s1",
|
||||
};
|
||||
alarm.HistorizeToAveva.ShouldBeTrue();
|
||||
alarm.Retain.ShouldBeTrue();
|
||||
alarm.Severity.ShouldBe(500);
|
||||
alarm.Enabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_keyed_on_ScriptedAlarmId_not_generation_scoped()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
|
||||
entity.GetTableName().ShouldBe("ScriptedAlarmState");
|
||||
|
||||
var pk = entity.FindPrimaryKey().ShouldNotBeNull();
|
||||
pk.Properties.Count.ShouldBe(1);
|
||||
pk.Properties[0].Name.ShouldBe(nameof(ScriptedAlarmState.ScriptedAlarmId));
|
||||
|
||||
// State is NOT generation-scoped — GenerationId column should not exist per plan decision #14.
|
||||
entity.FindProperty("GenerationId").ShouldBeNull(
|
||||
"ack state follows alarm identity across generations");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_default_state_values_match_Part9_initial_states()
|
||||
{
|
||||
var state = new ScriptedAlarmState
|
||||
{
|
||||
ScriptedAlarmId = "a1",
|
||||
EnabledState = "Enabled",
|
||||
AckedState = "Unacknowledged",
|
||||
ConfirmedState = "Unconfirmed",
|
||||
ShelvingState = "Unshelved",
|
||||
};
|
||||
state.CommentsJson.ShouldBe("[]");
|
||||
state.LastAckUser.ShouldBeNull();
|
||||
state.LastAckUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_has_JSON_check_constraint_on_CommentsJson()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
|
||||
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
||||
checks.ShouldContain("CK_ScriptedAlarmState_CommentsJson_IsJson");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_new_entities_exposed_via_DbSet()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
ctx.Scripts.ShouldNotBeNull();
|
||||
ctx.VirtualTags.ShouldNotBeNull();
|
||||
ctx.ScriptedAlarms.ShouldNotBeNull();
|
||||
ctx.ScriptedAlarmStates.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddPhase7ScriptingTables_migration_exists_in_assembly()
|
||||
{
|
||||
// The migration type carries the design-time snapshot + Up/Down methods EF uses to
|
||||
// apply the schema. Missing = schema won't roll forward in deployments.
|
||||
var t = typeof(Migrations.AddPhase7ScriptingTables);
|
||||
t.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ResilientConfigReaderTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-reader-{Guid.NewGuid():N}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_root)) return;
|
||||
foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
|
||||
File.SetAttributes(f, FileAttributes.Normal);
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDbSucceeds_ReturnsValue_MarksFresh()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag { };
|
||||
flag.MarkStale(); // pre-existing stale state
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance);
|
||||
|
||||
var result = await reader.ReadAsync(
|
||||
"cluster-a",
|
||||
_ => ValueTask.FromResult("fresh-from-db"),
|
||||
_ => "from-cache",
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe("fresh-from-db");
|
||||
flag.IsStale.ShouldBeFalse("successful central-DB read clears stale flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDbFails_ExhaustsRetries_FallsBackToCache_MarksStale()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "cluster-a", GenerationId = 99, CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = "{\"cached\":true}",
|
||||
});
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 2);
|
||||
var attempts = 0;
|
||||
|
||||
var result = await reader.ReadAsync(
|
||||
"cluster-a",
|
||||
_ =>
|
||||
{
|
||||
attempts++;
|
||||
throw new InvalidOperationException("SQL dead");
|
||||
#pragma warning disable CS0162
|
||||
return ValueTask.FromResult("never");
|
||||
#pragma warning restore CS0162
|
||||
},
|
||||
snap => snap.PayloadJson,
|
||||
CancellationToken.None);
|
||||
|
||||
attempts.ShouldBe(3, "1 initial + 2 retries = 3 attempts");
|
||||
result.ShouldBe("{\"cached\":true}");
|
||||
flag.IsStale.ShouldBeTrue("cache fallback flips stale flag true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(async () =>
|
||||
{
|
||||
await reader.ReadAsync<string>(
|
||||
"cluster-a",
|
||||
_ => throw new InvalidOperationException("SQL dead"),
|
||||
_ => "never",
|
||||
CancellationToken.None);
|
||||
});
|
||||
|
||||
flag.IsStale.ShouldBeFalse("no snapshot ever served, so flag stays whatever it was");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_NotRetried()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 5);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var attempts = 0;
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await reader.ReadAsync<string>(
|
||||
"cluster-a",
|
||||
ct =>
|
||||
{
|
||||
attempts++;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return ValueTask.FromResult("ok");
|
||||
},
|
||||
_ => "cache",
|
||||
cts.Token);
|
||||
});
|
||||
|
||||
attempts.ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StaleConfigFlagTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_IsFresh()
|
||||
{
|
||||
new StaleConfigFlag().IsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkStale_ThenFresh_Toggles()
|
||||
{
|
||||
var flag = new StaleConfigFlag();
|
||||
flag.MarkStale();
|
||||
flag.IsStale.ShouldBeTrue();
|
||||
flag.MarkFresh();
|
||||
flag.IsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentWrites_Converge()
|
||||
{
|
||||
var flag = new StaleConfigFlag();
|
||||
Parallel.For(0, 1000, i =>
|
||||
{
|
||||
if (i % 2 == 0) flag.MarkStale(); else flag.MarkFresh();
|
||||
});
|
||||
flag.MarkFresh();
|
||||
flag.IsStale.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Spins up a dedicated test database, applies the EF migrations against it, and exposes a
|
||||
/// <see cref="SqlConnection"/> factory. Disposed at collection teardown (drops the DB).
|
||||
/// Gated by the <c>OTOPCUA_CONFIG_TEST_SERVER</c> env var so CI runs can opt in explicitly;
|
||||
/// local runs default to the dev container on <c>localhost:14330</c>.
|
||||
/// </summary>
|
||||
public sealed class SchemaComplianceFixture : IDisposable
|
||||
{
|
||||
private const string DefaultServer = "localhost,14330";
|
||||
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
|
||||
|
||||
public string DatabaseName { get; }
|
||||
public string ConnectionString { get; }
|
||||
|
||||
public SchemaComplianceFixture()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
|
||||
var saPassword = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
|
||||
|
||||
DatabaseName = $"OtOpcUaConfig_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
|
||||
ConnectionString =
|
||||
$"Server={server};Database={DatabaseName};User Id=sa;Password={saPassword};TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(ConnectionString)
|
||||
.Options;
|
||||
|
||||
using var ctx = new OtOpcUaConfigDbContext(options);
|
||||
ctx.Database.Migrate();
|
||||
}
|
||||
|
||||
public SqlConnection OpenConnection()
|
||||
{
|
||||
var conn = new SqlConnection(ConnectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var masterConnection =
|
||||
new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString;
|
||||
|
||||
using var conn = new SqlConnection(masterConnection);
|
||||
conn.Open();
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
IF DB_ID(N'{DatabaseName}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE [{DatabaseName}];
|
||||
END";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(SchemaComplianceCollection))]
|
||||
public sealed class SchemaComplianceCollection : ICollectionFixture<SchemaComplianceFixture>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Introspects the applied schema via <c>sys.*</c> / <c>INFORMATION_SCHEMA.*</c> to confirm that
|
||||
/// the Fluent-API DbContext produces the exact structure specified in
|
||||
/// <c>docs/v2/config-db-schema.md</c>. Any change here is a deliberate decision — update the
|
||||
/// schema doc first, then these tests.
|
||||
/// </summary>
|
||||
[Trait("Category", "SchemaCompliance")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class SchemaComplianceTests
|
||||
{
|
||||
private readonly SchemaComplianceFixture _fixture;
|
||||
|
||||
public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public void All_expected_tables_exist()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
"ServerCluster", "ClusterNode", "ClusterNodeCredential", "ClusterNodeGenerationState",
|
||||
"ConfigGeneration", "ConfigAuditLog",
|
||||
"Namespace", "UnsArea", "UnsLine",
|
||||
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag",
|
||||
"NodeAcl", "ExternalIdReservation",
|
||||
"DriverHostStatus",
|
||||
"DriverInstanceResilienceStatus",
|
||||
"LdapGroupRoleMapping",
|
||||
"EquipmentImportBatch",
|
||||
"EquipmentImportRow",
|
||||
"Script", "ScriptedAlarm", "ScriptedAlarmState",
|
||||
};
|
||||
|
||||
var actual = QueryStrings(@"
|
||||
SELECT name FROM sys.tables WHERE name <> '__EFMigrationsHistory' ORDER BY name;").ToHashSet();
|
||||
|
||||
foreach (var table in expected)
|
||||
actual.ShouldContain(table, $"missing table: {table}");
|
||||
|
||||
actual.Count.ShouldBe(expected.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filtered_unique_indexes_match_schema_spec()
|
||||
{
|
||||
// (IndexName, Filter, Uniqueness) tuples — from OtOpcUaConfigDbContext Fluent config.
|
||||
// Kept here as a spec-level source of truth; the test ensures EF generated them verbatim.
|
||||
var expected = new[]
|
||||
{
|
||||
("UX_ClusterNode_Primary_Per_Cluster", "([RedundancyRole]='Primary')"),
|
||||
("UX_ClusterNodeCredential_Value", "([Enabled]=(1))"),
|
||||
("UX_ConfigGeneration_Draft_Per_Cluster", "([Status]='Draft')"),
|
||||
("UX_ExternalIdReservation_KindValue_Active", "([ReleasedAt] IS NULL)"),
|
||||
};
|
||||
|
||||
var rows = QueryRows(@"
|
||||
SELECT i.name AS IndexName, i.filter_definition
|
||||
FROM sys.indexes i
|
||||
WHERE i.is_unique = 1 AND i.has_filter = 1;",
|
||||
r => (Name: r.GetString(0), Filter: r.IsDBNull(1) ? null : r.GetString(1)));
|
||||
|
||||
foreach (var (name, filter) in expected)
|
||||
{
|
||||
var match = rows.FirstOrDefault(x => x.Name == name);
|
||||
match.Name.ShouldBe(name, $"missing filtered unique index: {name}");
|
||||
NormalizeFilter(match.Filter).ShouldBe(NormalizeFilter(filter),
|
||||
$"filter predicate for {name} drifted");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_constraints_match_schema_spec()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
"CK_ServerCluster_RedundancyMode_NodeCount",
|
||||
"CK_Device_DeviceConfig_IsJson",
|
||||
"CK_DriverInstance_DriverConfig_IsJson",
|
||||
"CK_DriverInstance_ResilienceConfig_IsJson",
|
||||
"CK_PollGroup_IntervalMs_Min",
|
||||
"CK_Tag_TagConfig_IsJson",
|
||||
"CK_ConfigAuditLog_DetailsJson_IsJson",
|
||||
};
|
||||
|
||||
var actual = QueryStrings("SELECT name FROM sys.check_constraints ORDER BY name;").ToHashSet();
|
||||
|
||||
foreach (var ck in expected)
|
||||
actual.ShouldContain(ck, $"missing CHECK constraint: {ck}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Json_check_constraints_use_IsJson_function()
|
||||
{
|
||||
var rows = QueryRows(@"
|
||||
SELECT cc.name, cc.definition
|
||||
FROM sys.check_constraints cc
|
||||
WHERE cc.name LIKE 'CK_%_IsJson';",
|
||||
r => (Name: r.GetString(0), Definition: r.GetString(1)));
|
||||
|
||||
rows.Count.ShouldBeGreaterThanOrEqualTo(4);
|
||||
|
||||
foreach (var (name, definition) in rows)
|
||||
definition.ShouldContain("isjson(", Case.Insensitive,
|
||||
$"{name} definition does not call ISJSON: {definition}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigGeneration_Status_uses_nvarchar_enum_storage()
|
||||
{
|
||||
var rows = QueryRows(@"
|
||||
SELECT c.COLUMN_NAME, c.DATA_TYPE, c.CHARACTER_MAXIMUM_LENGTH
|
||||
FROM INFORMATION_SCHEMA.COLUMNS c
|
||||
WHERE c.TABLE_NAME = 'ConfigGeneration' AND c.COLUMN_NAME = 'Status';",
|
||||
r => (Column: r.GetString(0), Type: r.GetString(1), Length: r.IsDBNull(2) ? (int?)null : r.GetInt32(2)));
|
||||
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].Type.ShouldBe("nvarchar");
|
||||
rows[0].Length.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equipment_carries_Opc40010_identity_fields()
|
||||
{
|
||||
var columns = QueryStrings(@"
|
||||
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Equipment';")
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var col in new[]
|
||||
{
|
||||
"EquipmentUuid", "EquipmentId", "MachineCode", "ZTag", "SAPID",
|
||||
"Manufacturer", "Model", "SerialNumber",
|
||||
})
|
||||
columns.ShouldContain(col, $"Equipment missing expected column: {col}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Namespace_has_same_cluster_invariant_index()
|
||||
{
|
||||
// Decision #122: namespace logical IDs unique within a cluster + generation. The composite
|
||||
// unique index enforces that trust boundary.
|
||||
var indexes = QueryStrings(@"
|
||||
SELECT i.name
|
||||
FROM sys.indexes i
|
||||
JOIN sys.tables t ON i.object_id = t.object_id
|
||||
WHERE t.name = 'Namespace' AND i.is_unique = 1;").ToList();
|
||||
|
||||
indexes.ShouldContain("UX_Namespace_Generation_LogicalId_Cluster");
|
||||
}
|
||||
|
||||
private List<string> QueryStrings(string sql)
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
using var cmd = new SqlCommand(sql, conn);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var result = new List<string>();
|
||||
while (reader.Read())
|
||||
result.Add(reader.GetString(0));
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<T> QueryRows<T>(string sql, Func<SqlDataReader, T> project)
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
using var cmd = new SqlCommand(sql, conn);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
var result = new List<T>();
|
||||
while (reader.Read())
|
||||
result.Add(project(reader));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? NormalizeFilter(string? filter) =>
|
||||
filter?.Replace(" ", string.Empty).Replace("(", string.Empty).Replace(")", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Happy-path + representative error-path coverage per Task B.2 acceptance. Each test seeds its
|
||||
/// own cluster + node + credential, creates a draft, exercises one proc, then cleans up at the
|
||||
/// fixture level (the fixture drops the DB in Dispose).
|
||||
/// </summary>
|
||||
[Trait("Category", "StoredProcedures")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class StoredProceduresTests
|
||||
{
|
||||
private readonly SchemaComplianceFixture _fixture;
|
||||
|
||||
public StoredProceduresTests(SchemaComplianceFixture fixture) => _fixture = fixture;
|
||||
|
||||
[Fact]
|
||||
public void Publish_then_GetCurrent_returns_the_published_generation()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, nodeId, _, draftId) = SeedClusterWithDraft(conn, suffix: "pub1");
|
||||
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draftId));
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c";
|
||||
cmd.Parameters.AddWithValue("n", nodeId);
|
||||
cmd.Parameters.AddWithValue("c", clusterId);
|
||||
using var r = cmd.ExecuteReader();
|
||||
r.Read().ShouldBeTrue("proc should return exactly one row");
|
||||
r.GetInt64(0).ShouldBe(draftId);
|
||||
r.GetString(2).ShouldBe("Published");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCurrent_rejects_caller_not_bound_to_node()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, _) = SeedClusterWithDraft(conn, suffix: "unauth");
|
||||
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
Exec(conn, "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c",
|
||||
("n", "ghost-node"), ("c", clusterId)));
|
||||
ex.Message.ShouldContain("Unauthorized");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_second_draft_supersedes_first()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draft1) = SeedClusterWithDraft(conn, suffix: "sup");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draft1));
|
||||
|
||||
var draft2 = CreateDraft(conn, clusterId);
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draft2));
|
||||
|
||||
var status1 = Scalar<string>(conn,
|
||||
"SELECT Status FROM dbo.ConfigGeneration WHERE GenerationId = @g", ("g", draft1));
|
||||
var status2 = Scalar<string>(conn,
|
||||
"SELECT Status FROM dbo.ConfigGeneration WHERE GenerationId = @g", ("g", draft2));
|
||||
status1.ShouldBe("Superseded");
|
||||
status2.ShouldBe("Published");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_rejects_non_draft_generation()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "twice");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draftId));
|
||||
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draftId)));
|
||||
ex.Message.ShouldContain("not in Draft");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateDraft_rejects_orphan_tag()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "orphan");
|
||||
Exec(conn, @"INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
|
||||
VALUES (@g, 'tag-1', 'missing-driver', 'X', 'Int32', 'Read', 0, '{}')",
|
||||
("g", draftId));
|
||||
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
Exec(conn, "EXEC dbo.sp_ValidateDraft @DraftGenerationId=@g", ("g", draftId)));
|
||||
ex.Message.ShouldContain("unresolved DriverInstanceId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rollback_creates_new_published_generation_and_clones_rows()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "rb");
|
||||
SeedMinimalDriverRow(conn, draftId, clusterId, driverInstanceId: "drv-a");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draftId));
|
||||
|
||||
Exec(conn, "EXEC dbo.sp_RollbackToGeneration @ClusterId=@c, @TargetGenerationId=@g, @Notes='test'",
|
||||
("c", clusterId), ("g", draftId));
|
||||
|
||||
var newlyPublishedCount = Scalar<int>(conn,
|
||||
@"SELECT COUNT(*) FROM dbo.ConfigGeneration
|
||||
WHERE ClusterId = @c AND Status = 'Published' AND GenerationId <> @g",
|
||||
("c", clusterId), ("g", draftId));
|
||||
newlyPublishedCount.ShouldBe(1);
|
||||
|
||||
var driverClonedCount = Scalar<int>(conn,
|
||||
@"SELECT COUNT(*) FROM dbo.DriverInstance di
|
||||
JOIN dbo.ConfigGeneration cg ON cg.GenerationId = di.GenerationId
|
||||
WHERE cg.ClusterId = @c AND cg.Status = 'Published' AND di.DriverInstanceId = 'drv-a'",
|
||||
("c", clusterId));
|
||||
driverClonedCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDiff_returns_Added_for_driver_present_only_in_target()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var (clusterId, _, _, draft1) = SeedClusterWithDraft(conn, suffix: "diff");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draft1));
|
||||
|
||||
var draft2 = CreateDraft(conn, clusterId);
|
||||
SeedMinimalDriverRow(conn, draft2, clusterId, driverInstanceId: "drv-added");
|
||||
Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g",
|
||||
("c", clusterId), ("g", draft2));
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_ComputeGenerationDiff @FromGenerationId=@f, @ToGenerationId=@t";
|
||||
cmd.Parameters.AddWithValue("f", draft1);
|
||||
cmd.Parameters.AddWithValue("t", draft2);
|
||||
using var r = cmd.ExecuteReader();
|
||||
var diffs = new List<(string Table, string Id, string Kind)>();
|
||||
while (r.Read())
|
||||
diffs.Add((r.GetString(0), r.GetString(1), r.GetString(2)));
|
||||
|
||||
diffs.ShouldContain(d => d.Table == "DriverInstance" && d.Id == "drv-added" && d.Kind == "Added");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleaseReservation_requires_nonempty_reason()
|
||||
{
|
||||
using var conn = _fixture.OpenConnection();
|
||||
var ex = Should.Throw<SqlException>(() =>
|
||||
Exec(conn, "EXEC dbo.sp_ReleaseExternalIdReservation @Kind='ZTag', @Value='X', @ReleaseReason=''"));
|
||||
ex.Message.ShouldContain("ReleaseReason is required");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>Creates a cluster, one node, one credential bound to the current SUSER_SNAME(), and an empty Draft.</summary>
|
||||
private static (string ClusterId, string NodeId, string Credential, long DraftId)
|
||||
SeedClusterWithDraft(SqlConnection conn, string suffix)
|
||||
{
|
||||
var clusterId = $"cluster-{suffix}";
|
||||
var nodeId = $"node-{suffix}-a";
|
||||
|
||||
// Every test uses the same SUSER_SNAME() ('sa' by default), and the credential unique index
|
||||
// is filtered on Enabled=1 across (Kind, Value) globally. To avoid collisions across tests
|
||||
// sharing one DB, we disable old credentials first.
|
||||
Exec(conn, "UPDATE dbo.ClusterNodeCredential SET Enabled = 0 WHERE Value = SUSER_SNAME();");
|
||||
|
||||
Exec(conn,
|
||||
@"INSERT dbo.ServerCluster (ClusterId, Name, Enterprise, Site, RedundancyMode, NodeCount, Enabled, CreatedBy)
|
||||
VALUES (@c, @c, 'zb', @s, 'None', 1, 1, SUSER_SNAME());
|
||||
INSERT dbo.ClusterNode (NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES (@n, @c, 'Primary', 'localhost', 4840, 5001, CONCAT('urn:localhost:', @s), 200, 1, SUSER_SNAME());
|
||||
INSERT dbo.ClusterNodeCredential (NodeId, Kind, Value, Enabled, CreatedBy)
|
||||
VALUES (@n, 'SqlLogin', SUSER_SNAME(), 1, SUSER_SNAME());",
|
||||
("c", clusterId), ("n", nodeId), ("s", suffix));
|
||||
|
||||
var draftId = CreateDraft(conn, clusterId);
|
||||
return (clusterId, nodeId, "sa", draftId);
|
||||
}
|
||||
|
||||
private static long CreateDraft(SqlConnection conn, string clusterId)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy)
|
||||
VALUES (@c, 'Draft', SYSUTCDATETIME(), SUSER_SNAME());
|
||||
SELECT CAST(SCOPE_IDENTITY() AS bigint);";
|
||||
cmd.Parameters.AddWithValue("c", clusterId);
|
||||
return (long)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
private static void SeedMinimalDriverRow(SqlConnection conn, long genId, string clusterId, string driverInstanceId)
|
||||
{
|
||||
Exec(conn,
|
||||
@"INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||
VALUES (@g, @ns, @c, 'Equipment', 'urn:ns', 1);
|
||||
INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
|
||||
VALUES (@g, @drv, @c, @ns, 'drv', 'ModbusTcp', 1, '{}');",
|
||||
("g", genId), ("c", clusterId), ("ns", $"ns-{driverInstanceId}"), ("drv", driverInstanceId));
|
||||
}
|
||||
|
||||
private static void Exec(SqlConnection conn, string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static T Scalar<T>(SqlConnection conn, string sql, params (string Name, object Value)[] parameters)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value);
|
||||
return (T)cmd.ExecuteScalar()!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,100 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the <see cref="AlarmConditionInfo"/> record extension added in PR 2.1.
|
||||
/// Five sub-attribute references (InAlarmRef, PriorityRef, DescAttrNameRef, AckedRef,
|
||||
/// AckMsgWriteRef) carry the driver-side tag references the server-level alarm-condition
|
||||
/// service uses to subscribe to live alarm-state attributes and route ack writes.
|
||||
/// </summary>
|
||||
public sealed class AlarmConditionInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void LegacyThreeArgConstructor_StillCompiles_AndDefaultsRefsToNull()
|
||||
{
|
||||
var info = new AlarmConditionInfo(
|
||||
SourceName: "Tank.HiHi",
|
||||
InitialSeverity: AlarmSeverity.High,
|
||||
InitialDescription: "High-high alarm");
|
||||
|
||||
info.SourceName.ShouldBe("Tank.HiHi");
|
||||
info.InitialSeverity.ShouldBe(AlarmSeverity.High);
|
||||
info.InitialDescription.ShouldBe("High-high alarm");
|
||||
info.InAlarmRef.ShouldBeNull();
|
||||
info.PriorityRef.ShouldBeNull();
|
||||
info.DescAttrNameRef.ShouldBeNull();
|
||||
info.AckedRef.ShouldBeNull();
|
||||
info.AckMsgWriteRef.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullConstructor_PopulatesAllFiveSubAttributeRefs()
|
||||
{
|
||||
var info = new AlarmConditionInfo(
|
||||
SourceName: "Tank1.HiAlarm",
|
||||
InitialSeverity: AlarmSeverity.Medium,
|
||||
InitialDescription: "Tank level high",
|
||||
InAlarmRef: "Tank1.HiAlarm.InAlarm",
|
||||
PriorityRef: "Tank1.HiAlarm.Priority",
|
||||
DescAttrNameRef: "Tank1.HiAlarm.DescAttrName",
|
||||
AckedRef: "Tank1.HiAlarm.Acked",
|
||||
AckMsgWriteRef: "Tank1.HiAlarm.AckMsg");
|
||||
|
||||
info.InAlarmRef.ShouldBe("Tank1.HiAlarm.InAlarm");
|
||||
info.PriorityRef.ShouldBe("Tank1.HiAlarm.Priority");
|
||||
info.DescAttrNameRef.ShouldBe("Tank1.HiAlarm.DescAttrName");
|
||||
info.AckedRef.ShouldBe("Tank1.HiAlarm.Acked");
|
||||
info.AckMsgWriteRef.ShouldBe("Tank1.HiAlarm.AckMsg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordEquality_ComparesAllEightFields()
|
||||
{
|
||||
var a = new AlarmConditionInfo(
|
||||
"T.Alarm", AlarmSeverity.Low, "desc",
|
||||
"T.Alarm.InAlarm", "T.Alarm.Priority", "T.Alarm.DescAttrName",
|
||||
"T.Alarm.Acked", "T.Alarm.AckMsg");
|
||||
|
||||
var b = new AlarmConditionInfo(
|
||||
"T.Alarm", AlarmSeverity.Low, "desc",
|
||||
"T.Alarm.InAlarm", "T.Alarm.Priority", "T.Alarm.DescAttrName",
|
||||
"T.Alarm.Acked", "T.Alarm.AckMsg");
|
||||
|
||||
a.ShouldBe(b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordEquality_DistinctWhenAnyRefDiffers()
|
||||
{
|
||||
var baseInfo = new AlarmConditionInfo(
|
||||
"T.Alarm", AlarmSeverity.Low, "desc",
|
||||
InAlarmRef: "T.Alarm.InAlarm");
|
||||
|
||||
var differingAckRef = baseInfo with { AckedRef = "T.Alarm.Acked" };
|
||||
|
||||
baseInfo.ShouldNotBe(differingAckRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithExpression_AllowsPartialUpdates()
|
||||
{
|
||||
var legacy = new AlarmConditionInfo("S", AlarmSeverity.Medium, null);
|
||||
|
||||
var enriched = legacy with
|
||||
{
|
||||
InAlarmRef = "S.InAlarm",
|
||||
AckedRef = "S.Acked",
|
||||
AckMsgWriteRef = "S.AckMsg",
|
||||
};
|
||||
|
||||
enriched.SourceName.ShouldBe("S");
|
||||
enriched.InAlarmRef.ShouldBe("S.InAlarm");
|
||||
enriched.PriorityRef.ShouldBeNull();
|
||||
enriched.DescAttrNameRef.ShouldBeNull();
|
||||
enriched.AckedRef.ShouldBe("S.Acked");
|
||||
enriched.AckMsgWriteRef.ShouldBe("S.AckMsg");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
public sealed class DriverTypeRegistryTests
|
||||
{
|
||||
private static DriverTypeMetadata SampleMetadata(
|
||||
string typeName = "Modbus",
|
||||
NamespaceKindCompatibility allowed = NamespaceKindCompatibility.Equipment,
|
||||
DriverTier tier = DriverTier.B) =>
|
||||
new(typeName, allowed,
|
||||
DriverConfigJsonSchema: "{\"type\": \"object\"}",
|
||||
DeviceConfigJsonSchema: "{\"type\": \"object\"}",
|
||||
TagConfigJsonSchema: "{\"type\": \"object\"}",
|
||||
Tier: tier);
|
||||
|
||||
[Fact]
|
||||
public void Register_ThenGet_RoundTrips()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
var metadata = SampleMetadata();
|
||||
|
||||
registry.Register(metadata);
|
||||
|
||||
registry.Get("Modbus").ShouldBe(metadata);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
[InlineData(DriverTier.C)]
|
||||
public void Register_Requires_NonNullTier(DriverTier tier)
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
var metadata = SampleMetadata(typeName: $"Driver-{tier}", tier: tier);
|
||||
|
||||
registry.Register(metadata);
|
||||
|
||||
registry.Get(metadata.TypeName).Tier.ShouldBe(tier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IsCaseInsensitive()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Galaxy"));
|
||||
|
||||
registry.Get("galaxy").ShouldNotBeNull();
|
||||
registry.Get("GALAXY").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_UnknownType_Throws()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<KeyNotFoundException>(() => registry.Get("UnregisteredType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_UnknownType_ReturnsNull()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
registry.TryGet("UnregisteredType").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_DuplicateType_Throws()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("Modbus")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_DuplicateTypeIsCaseInsensitive()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("modbus")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_ReturnsRegisteredTypes()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
registry.Register(SampleMetadata("S7"));
|
||||
registry.Register(SampleMetadata("Galaxy", NamespaceKindCompatibility.SystemPlatform));
|
||||
|
||||
var all = registry.All();
|
||||
|
||||
all.Count.ShouldBe(3);
|
||||
all.Select(m => m.TypeName).ShouldBe(new[] { "Modbus", "S7", "Galaxy" }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamespaceKindCompatibility_FlagsAreBitmask()
|
||||
{
|
||||
// Per decision #111 — driver types like OpcUaClient may be valid for multiple namespace kinds.
|
||||
var both = NamespaceKindCompatibility.Equipment | NamespaceKindCompatibility.SystemPlatform;
|
||||
|
||||
both.HasFlag(NamespaceKindCompatibility.Equipment).ShouldBeTrue();
|
||||
both.HasFlag(NamespaceKindCompatibility.SystemPlatform).ShouldBeTrue();
|
||||
both.HasFlag(NamespaceKindCompatibility.Simulated).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Get_RejectsEmptyTypeName(string? typeName)
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
Should.Throw<ArgumentException>(() => registry.Get(typeName!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Structural contract tests for the historian data-source surface added in PR 1.1.
|
||||
/// Asserts the type shape — implementations are tested in their own projects.
|
||||
/// </summary>
|
||||
public sealed class IHistorianDataSourceContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void Interface_LivesInRootNamespace()
|
||||
{
|
||||
typeof(IHistorianDataSource).Namespace
|
||||
.ShouldBe("ZB.MOM.WW.OtOpcUa.Core.Abstractions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interface_IsPublic()
|
||||
{
|
||||
typeof(IHistorianDataSource).IsPublic.ShouldBeTrue();
|
||||
typeof(IHistorianDataSource).IsInterface.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interface_ExtendsIDisposable()
|
||||
{
|
||||
typeof(IDisposable).IsAssignableFrom(typeof(IHistorianDataSource))
|
||||
.ShouldBeTrue("data sources own backend connections; the server disposes them on shutdown");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ReadRawAsync", typeof(Task<HistoryReadResult>))]
|
||||
[InlineData("ReadProcessedAsync", typeof(Task<HistoryReadResult>))]
|
||||
[InlineData("ReadAtTimeAsync", typeof(Task<HistoryReadResult>))]
|
||||
[InlineData("ReadEventsAsync", typeof(Task<HistoricalEventsResult>))]
|
||||
public void ReadMethods_ReturnExpectedTaskShape(string methodName, Type expectedReturnType)
|
||||
{
|
||||
var method = typeof(IHistorianDataSource).GetMethod(methodName);
|
||||
method.ShouldNotBeNull();
|
||||
method!.ReturnType.ShouldBe(expectedReturnType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_IsSynchronous()
|
||||
{
|
||||
var method = typeof(IHistorianDataSource).GetMethod("GetHealthSnapshot");
|
||||
method.ShouldNotBeNull();
|
||||
method!.ReturnType.ShouldBe(typeof(HistorianHealthSnapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthSnapshot_AcceptsEmptyClusterNodeList()
|
||||
{
|
||||
var snapshot = new HistorianHealthSnapshot(
|
||||
TotalQueries: 0,
|
||||
TotalSuccesses: 0,
|
||||
TotalFailures: 0,
|
||||
ConsecutiveFailures: 0,
|
||||
LastSuccessTime: null,
|
||||
LastFailureTime: null,
|
||||
LastError: null,
|
||||
ProcessConnectionOpen: false,
|
||||
EventConnectionOpen: false,
|
||||
ActiveProcessNode: null,
|
||||
ActiveEventNode: null,
|
||||
Nodes: Array.Empty<HistorianClusterNodeState>());
|
||||
|
||||
snapshot.Nodes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthSnapshot_PreservesClusterNodes()
|
||||
{
|
||||
var node = new HistorianClusterNodeState(
|
||||
Name: "hist-01",
|
||||
IsHealthy: true,
|
||||
CooldownUntil: null,
|
||||
FailureCount: 0,
|
||||
LastError: null,
|
||||
LastFailureTime: null);
|
||||
|
||||
var snapshot = new HistorianHealthSnapshot(
|
||||
TotalQueries: 5,
|
||||
TotalSuccesses: 5,
|
||||
TotalFailures: 0,
|
||||
ConsecutiveFailures: 0,
|
||||
LastSuccessTime: new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc),
|
||||
LastFailureTime: null,
|
||||
LastError: null,
|
||||
ProcessConnectionOpen: true,
|
||||
EventConnectionOpen: true,
|
||||
ActiveProcessNode: "hist-01",
|
||||
ActiveEventNode: "hist-01",
|
||||
Nodes: new[] { node });
|
||||
|
||||
snapshot.Nodes.Count.ShouldBe(1);
|
||||
snapshot.Nodes[0].ShouldBe(node);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterNodeState_RecordEqualityByValue()
|
||||
{
|
||||
var a = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
|
||||
var b = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
|
||||
|
||||
a.ShouldBe(b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterNodeState_DistinctByAnyField()
|
||||
{
|
||||
var healthy = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
|
||||
var unhealthy = new HistorianClusterNodeState("hist-01", false, null, 1, "boom", null);
|
||||
|
||||
healthy.ShouldNotBe(unhealthy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that <c>Core.Abstractions</c> stays a true contract project — it must not depend on
|
||||
/// any implementation type, any other OtOpcUa project, or anything beyond BCL + System types.
|
||||
/// Per <c>docs/v2/plan.md</c> decision #59 (Core.Abstractions internal-only for now; design as
|
||||
/// if public to minimize churn later).
|
||||
/// </summary>
|
||||
public sealed class InterfaceIndependenceTests
|
||||
{
|
||||
private static readonly Assembly Assembly = typeof(IDriver).Assembly;
|
||||
|
||||
[Fact]
|
||||
public void Assembly_HasNoReferencesOutsideBcl()
|
||||
{
|
||||
// Allowed reference assembly name prefixes — BCL + the assembly itself.
|
||||
var allowed = new[]
|
||||
{
|
||||
"System",
|
||||
"Microsoft.Win32",
|
||||
"netstandard",
|
||||
"mscorlib",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions",
|
||||
};
|
||||
|
||||
var referenced = Assembly.GetReferencedAssemblies();
|
||||
var disallowed = referenced
|
||||
.Where(r => !allowed.Any(a => r.Name!.StartsWith(a, StringComparison.Ordinal)))
|
||||
.ToList();
|
||||
|
||||
disallowed.ShouldBeEmpty(
|
||||
$"Core.Abstractions must reference only BCL/System assemblies. " +
|
||||
$"Found disallowed references: {string.Join(", ", disallowed.Select(a => a.Name))}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllPublicTypes_LiveInRootNamespace()
|
||||
{
|
||||
// Per the decision-#59 "design as if public" rule — no nested sub-namespaces; one flat surface.
|
||||
var publicTypes = Assembly.GetExportedTypes();
|
||||
var nonRoot = publicTypes
|
||||
.Where(t => t.Namespace != "ZB.MOM.WW.OtOpcUa.Core.Abstractions")
|
||||
.ToList();
|
||||
|
||||
nonRoot.ShouldBeEmpty(
|
||||
$"Core.Abstractions should expose all public types in the root namespace. " +
|
||||
$"Found types in other namespaces: {string.Join(", ", nonRoot.Select(t => $"{t.FullName}"))}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(IDriver))]
|
||||
[InlineData(typeof(ITagDiscovery))]
|
||||
[InlineData(typeof(IReadable))]
|
||||
[InlineData(typeof(IWritable))]
|
||||
[InlineData(typeof(ISubscribable))]
|
||||
[InlineData(typeof(IAlarmSource))]
|
||||
[InlineData(typeof(IHistoryProvider))]
|
||||
[InlineData(typeof(IRediscoverable))]
|
||||
[InlineData(typeof(IHostConnectivityProbe))]
|
||||
[InlineData(typeof(IDriverConfigEditor))]
|
||||
[InlineData(typeof(IAddressSpaceBuilder))]
|
||||
public void EveryCapabilityInterface_IsPublic(Type type)
|
||||
{
|
||||
type.IsPublic.ShouldBeTrue($"{type.Name} must be public — drivers in separate assemblies implement it.");
|
||||
type.IsInterface.ShouldBeTrue($"{type.Name} must be an interface, not a class.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PollGroupEngineTests
|
||||
{
|
||||
private sealed class FakeSource
|
||||
{
|
||||
public ConcurrentDictionary<string, object?> Values { get; } = new();
|
||||
public int ReadCount;
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> refs, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref ReadCount);
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> snapshots = refs
|
||||
.Select(r => Values.TryGetValue(r, out var v)
|
||||
? new DataValueSnapshot(v, 0u, now, now)
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, now))
|
||||
.ToList();
|
||||
return Task.FromResult(snapshots);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initial_poll_force_raises_every_subscribed_tag()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["A"] = 1;
|
||||
src.Values["B"] = "hello";
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle h, string r, DataValueSnapshot s)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)));
|
||||
|
||||
var handle = engine.Subscribe(["A", "B"], TimeSpan.FromMilliseconds(200));
|
||||
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.Select(e => e.r).ShouldBe(["A", "B"], ignoreOrder: true);
|
||||
engine.Unsubscribe(handle).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_value_raises_only_once()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 42;
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await Task.Delay(500);
|
||||
engine.Unsubscribe(handle);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Value_change_raises_new_event()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
src.Values["X"] = 2;
|
||||
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
engine.Unsubscribe(handle);
|
||||
events.Last().Item3.Value.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_halts_the_loop()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
engine.Unsubscribe(handle).ShouldBeTrue();
|
||||
var afterUnsub = events.Count;
|
||||
|
||||
src.Values["X"] = 999;
|
||||
await Task.Delay(400);
|
||||
events.Count.ShouldBe(afterUnsub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interval_below_floor_is_clamped()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
|
||||
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue((h, r, s)),
|
||||
minInterval: TimeSpan.FromMilliseconds(200));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(5));
|
||||
await Task.Delay(300);
|
||||
engine.Unsubscribe(handle);
|
||||
|
||||
// 300 ms window, 200 ms floor, stable value → initial push + at most 1 extra poll.
|
||||
// With zero changes only the initial-data push fires.
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_subscriptions_are_independent()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["A"] = 1;
|
||||
src.Values["B"] = 2;
|
||||
|
||||
var a = new ConcurrentQueue<string>();
|
||||
var b = new ConcurrentQueue<string>();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) =>
|
||||
{
|
||||
if (r == "A") a.Enqueue(r);
|
||||
else if (r == "B") b.Enqueue(r);
|
||||
});
|
||||
|
||||
var ha = engine.Subscribe(["A"], TimeSpan.FromMilliseconds(100));
|
||||
var hb = engine.Subscribe(["B"], TimeSpan.FromMilliseconds(100));
|
||||
|
||||
await WaitForAsync(() => a.Count >= 1 && b.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
engine.Unsubscribe(ha);
|
||||
var aCount = a.Count;
|
||||
src.Values["B"] = 77;
|
||||
await WaitForAsync(() => b.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
a.Count.ShouldBe(aCount);
|
||||
b.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
engine.Unsubscribe(hb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_exception_does_not_crash_loop()
|
||||
{
|
||||
var throwCount = 0;
|
||||
var readCount = 0;
|
||||
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.Increment(ref readCount) <= 2)
|
||||
{
|
||||
Interlocked.Increment(ref throwCount);
|
||||
throw new InvalidOperationException("boom");
|
||||
}
|
||||
var now = DateTime.UtcNow;
|
||||
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
|
||||
refs.Select(r => new DataValueSnapshot(1, 0u, now, now)).ToList());
|
||||
}
|
||||
|
||||
var events = new ConcurrentQueue<string>();
|
||||
await using var engine = new PollGroupEngine(Reader,
|
||||
(h, r, s) => events.Enqueue(r));
|
||||
|
||||
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
engine.Unsubscribe(handle);
|
||||
|
||||
throwCount.ShouldBe(2);
|
||||
events.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_unknown_handle_returns_false()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync, (_, _, _) => { });
|
||||
|
||||
var foreign = new DummyHandle();
|
||||
engine.Unsubscribe(foreign).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveSubscriptionCount_tracks_lifecycle()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
await using var engine = new PollGroupEngine(src.ReadAsync, (_, _, _) => { });
|
||||
|
||||
engine.ActiveSubscriptionCount.ShouldBe(0);
|
||||
var h1 = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(200));
|
||||
var h2 = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(200));
|
||||
engine.ActiveSubscriptionCount.ShouldBe(2);
|
||||
|
||||
engine.Unsubscribe(h1);
|
||||
engine.ActiveSubscriptionCount.ShouldBe(1);
|
||||
engine.Unsubscribe(h2);
|
||||
engine.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_cancels_all_subscriptions()
|
||||
{
|
||||
var src = new FakeSource();
|
||||
src.Values["X"] = 1;
|
||||
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var engine = new PollGroupEngine(src.ReadAsync,
|
||||
(h, r, s) => events.Enqueue(r));
|
||||
|
||||
_ = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
_ = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
|
||||
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
await engine.DisposeAsync();
|
||||
engine.ActiveSubscriptionCount.ShouldBe(0);
|
||||
|
||||
var afterDispose = events.Count;
|
||||
await Task.Delay(300);
|
||||
// After dispose no more events — everything is cancelled.
|
||||
events.Count.ShouldBe(afterDispose);
|
||||
}
|
||||
|
||||
private sealed record DummyHandle : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "dummy";
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,286 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the durable SQLite store-and-forward queue behind the historian sink:
|
||||
/// round-trip Ack, backoff ladder on RetryPlease, dead-lettering on PermanentFail,
|
||||
/// capacity eviction, and retention-based dead-letter purge.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
private readonly ILogger _log;
|
||||
|
||||
public SqliteStoreAndForwardSinkTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-historian-{Guid.NewGuid():N}.sqlite");
|
||||
_log = new LoggerConfiguration().MinimumLevel.Verbose().CreateLogger();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { if (File.Exists(_dbPath)) File.Delete(_dbPath); } catch { }
|
||||
}
|
||||
|
||||
private sealed class FakeWriter : IAlarmHistorianWriter
|
||||
{
|
||||
public Queue<HistorianWriteOutcome> NextOutcomePerEvent { get; } = new();
|
||||
public HistorianWriteOutcome DefaultOutcome { get; set; } = HistorianWriteOutcome.Ack;
|
||||
public List<IReadOnlyList<AlarmHistorianEvent>> Batches { get; } = [];
|
||||
public Exception? ThrowOnce { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnce is not null)
|
||||
{
|
||||
var e = ThrowOnce;
|
||||
ThrowOnce = null;
|
||||
throw e;
|
||||
}
|
||||
Batches.Add(batch);
|
||||
var outcomes = new List<HistorianWriteOutcome>();
|
||||
for (var i = 0; i < batch.Count; i++)
|
||||
outcomes.Add(NextOutcomePerEvent.Count > 0 ? NextOutcomePerEvent.Dequeue() : DefaultOutcome);
|
||||
return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(outcomes);
|
||||
}
|
||||
}
|
||||
|
||||
private static AlarmHistorianEvent Event(string alarmId, DateTime? ts = null) => new(
|
||||
AlarmId: alarmId,
|
||||
EquipmentPath: "/Site/Line1/Cell",
|
||||
AlarmName: "HighTemp",
|
||||
AlarmTypeName: "LimitAlarm",
|
||||
Severity: AlarmSeverity.High,
|
||||
EventKind: "Activated",
|
||||
Message: "temp exceeded",
|
||||
User: "system",
|
||||
Comment: null,
|
||||
TimestampUtc: ts ?? DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueThenDrain_Ack_removes_row()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
sink.GetStatus().QueueDepth.ShouldBe(1);
|
||||
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
writer.Batches.Count.ShouldBe(1);
|
||||
writer.Batches[0].Count.ShouldBe(1);
|
||||
writer.Batches[0][0].AlarmId.ShouldBe("A1");
|
||||
var status = sink.GetStatus();
|
||||
status.QueueDepth.ShouldBe(0);
|
||||
status.DeadLetterDepth.ShouldBe(0);
|
||||
status.LastSuccessUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Drain_with_empty_queue_is_noop()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
writer.Batches.ShouldBeEmpty();
|
||||
sink.GetStatus().DrainState.ShouldBe(HistorianDrainState.Idle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryPlease_bumps_backoff_and_keeps_row()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease);
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
var before = sink.CurrentBackoff;
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
sink.CurrentBackoff.ShouldBeGreaterThan(before);
|
||||
sink.GetStatus().QueueDepth.ShouldBe(1, "row stays in queue for retry");
|
||||
sink.GetStatus().DrainState.ShouldBe(HistorianDrainState.BackingOff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ack_after_Retry_resets_backoff()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease);
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
sink.CurrentBackoff.ShouldBeGreaterThan(TimeSpan.FromSeconds(1) - TimeSpan.FromMilliseconds(1));
|
||||
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.Ack);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
sink.GetStatus().QueueDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PermanentFail_dead_letters_one_row_only()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.Ack);
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
|
||||
await sink.EnqueueAsync(Event("good"), CancellationToken.None);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
var status = sink.GetStatus();
|
||||
status.QueueDepth.ShouldBe(0, "good row acked");
|
||||
status.DeadLetterDepth.ShouldBe(1, "bad row dead-lettered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writer_exception_treated_as_retry_for_whole_batch()
|
||||
{
|
||||
var writer = new FakeWriter { ThrowOnce = new InvalidOperationException("pipe broken") };
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
var status = sink.GetStatus();
|
||||
status.QueueDepth.ShouldBe(1);
|
||||
status.LastError.ShouldBe("pipe broken");
|
||||
status.DrainState.ShouldBe(HistorianDrainState.BackingOff);
|
||||
|
||||
// Next drain after the writer recovers should Ack.
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
sink.GetStatus().QueueDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capacity_eviction_drops_oldest_nondeadlettered_row()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
using var sink = new SqliteStoreAndForwardSink(
|
||||
_dbPath, writer, _log, batchSize: 100, capacity: 3);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
await sink.EnqueueAsync(Event("A2"), CancellationToken.None);
|
||||
await sink.EnqueueAsync(Event("A3"), CancellationToken.None);
|
||||
// A4 enqueue must evict the oldest (A1).
|
||||
await sink.EnqueueAsync(Event("A4"), CancellationToken.None);
|
||||
|
||||
sink.GetStatus().QueueDepth.ShouldBe(3);
|
||||
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
var drained = writer.Batches[0].Select(e => e.AlarmId).ToArray();
|
||||
drained.ShouldNotContain("A1");
|
||||
drained.ShouldContain("A2");
|
||||
drained.ShouldContain("A3");
|
||||
drained.ShouldContain("A4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deadlettered_rows_are_purged_past_retention()
|
||||
{
|
||||
var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime clock = now;
|
||||
|
||||
var writer = new FakeWriter();
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
|
||||
using var sink = new SqliteStoreAndForwardSink(
|
||||
_dbPath, writer, _log, deadLetterRetention: TimeSpan.FromDays(30),
|
||||
clock: () => clock);
|
||||
|
||||
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
sink.GetStatus().DeadLetterDepth.ShouldBe(1);
|
||||
|
||||
// Advance past retention + tick drain (which runs PurgeAgedDeadLetters).
|
||||
clock = now.AddDays(31);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
sink.GetStatus().DeadLetterDepth.ShouldBe(0, "purged past retention");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDeadLettered_requeues_for_retry()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
sink.GetStatus().DeadLetterDepth.ShouldBe(1);
|
||||
|
||||
var revived = sink.RetryDeadLettered();
|
||||
revived.ShouldBe(1);
|
||||
|
||||
var status = sink.GetStatus();
|
||||
status.QueueDepth.ShouldBe(1);
|
||||
status.DeadLetterDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backoff_ladder_caps_at_60s()
|
||||
{
|
||||
var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
|
||||
// 10 retry rounds — ladder should cap at 60s.
|
||||
for (var i = 0; i < 10; i++)
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullAlarmHistorianSink_reports_disabled_status()
|
||||
{
|
||||
var s = NullAlarmHistorianSink.Instance.GetStatus();
|
||||
s.DrainState.ShouldBe(HistorianDrainState.Disabled);
|
||||
s.QueueDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullAlarmHistorianSink_swallows_enqueue()
|
||||
{
|
||||
// Should not throw or persist anything.
|
||||
await NullAlarmHistorianSink.Instance.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_rejects_bad_args()
|
||||
{
|
||||
var w = new FakeWriter();
|
||||
Should.Throw<ArgumentException>(() => new SqliteStoreAndForwardSink("", w, _log));
|
||||
Should.Throw<ArgumentNullException>(() => new SqliteStoreAndForwardSink(_dbPath, null!, _log));
|
||||
Should.Throw<ArgumentNullException>(() => new SqliteStoreAndForwardSink(_dbPath, w, null!));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, batchSize: 0));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, capacity: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposed_sink_rejects_enqueue()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
sink.Dispose();
|
||||
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
() => sink.EnqueueAsync(Event("A1"), CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
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);
|
||||
public int ActiveSubscriptionCount { get; private set; }
|
||||
|
||||
public void Set(string path, object? value, uint statusCode = 0u)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
|
||||
}
|
||||
|
||||
public void Push(string path, object? value, uint statusCode = 0u)
|
||||
{
|
||||
Set(path, value, statusCode);
|
||||
if (_subs.TryGetValue(path, out var list))
|
||||
{
|
||||
Action<string, DataValueSnapshot>[] snap;
|
||||
lock (list) { snap = list.ToArray(); }
|
||||
foreach (var obs in snap) obs(path, _values[path]);
|
||||
}
|
||||
}
|
||||
|
||||
public DataValueSnapshot ReadTag(string path)
|
||||
=> _values.TryGetValue(path, out var v) ? v
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
|
||||
|
||||
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
var list = _subs.GetOrAdd(path, _ => []);
|
||||
lock (list) { list.Add(observer); }
|
||||
ActiveSubscriptionCount++;
|
||||
return new Unsub(this, path, observer);
|
||||
}
|
||||
|
||||
private sealed class Unsub : IDisposable
|
||||
{
|
||||
private readonly FakeUpstream _up;
|
||||
private readonly string _path;
|
||||
private readonly Action<string, DataValueSnapshot> _observer;
|
||||
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
|
||||
{ _up = up; _path = path; _observer = observer; }
|
||||
public void Dispose()
|
||||
{
|
||||
if (_up._subs.TryGetValue(_path, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
if (list.Remove(_observer)) _up.ActiveSubscriptionCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MessageTemplateTests
|
||||
{
|
||||
private static DataValueSnapshot Good(object? v) =>
|
||||
new(v, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||
private static DataValueSnapshot Bad() =>
|
||||
new(null, 0x80050000u, null, DateTime.UtcNow);
|
||||
|
||||
private static DataValueSnapshot? Resolver(Dictionary<string, DataValueSnapshot> map, string path)
|
||||
=> map.TryGetValue(path, out var v) ? v : null;
|
||||
|
||||
[Fact]
|
||||
public void No_tokens_returns_template_unchanged()
|
||||
{
|
||||
MessageTemplate.Resolve("No tokens here", _ => null).ShouldBe("No tokens here");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_token_substituted()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot> { ["Tank/Temp"] = Good(75.5) };
|
||||
MessageTemplate.Resolve("Temp={Tank/Temp}C", p => Resolver(map, p)).ShouldBe("Temp=75.5C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_tokens_substituted()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["A"] = Good(10),
|
||||
["B"] = Good("on"),
|
||||
};
|
||||
MessageTemplate.Resolve("{A}/{B}", p => Resolver(map, p)).ShouldBe("10/on");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bad_quality_token_becomes_question_mark()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot> { ["Bad"] = Bad() };
|
||||
MessageTemplate.Resolve("value={Bad}", p => Resolver(map, p)).ShouldBe("value={?}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_path_becomes_question_mark()
|
||||
{
|
||||
MessageTemplate.Resolve("value={DoesNotExist}", _ => null).ShouldBe("value={?}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_value_with_good_quality_becomes_question_mark()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot> { ["X"] = Good(null) };
|
||||
MessageTemplate.Resolve("{X}", p => Resolver(map, p)).ShouldBe("{?}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tokens_with_slashes_and_dots_resolved()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["Line1/Pump.Speed"] = Good(1200),
|
||||
};
|
||||
MessageTemplate.Resolve("rpm={Line1/Pump.Speed}", p => Resolver(map, p))
|
||||
.ShouldBe("rpm=1200");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_template_returns_empty()
|
||||
{
|
||||
MessageTemplate.Resolve("", _ => null).ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_template_returns_empty_without_throwing()
|
||||
{
|
||||
MessageTemplate.Resolve(null!, _ => null).ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTokenPaths_returns_every_distinct_token()
|
||||
{
|
||||
var tokens = MessageTemplate.ExtractTokenPaths("{A}/{B}/{A}/{C}");
|
||||
tokens.ShouldBe(new[] { "A", "B", "A", "C" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTokenPaths_empty_for_tokenless_template()
|
||||
{
|
||||
MessageTemplate.ExtractTokenPaths("No tokens").ShouldBeEmpty();
|
||||
MessageTemplate.ExtractTokenPaths("").ShouldBeEmpty();
|
||||
MessageTemplate.ExtractTokenPaths(null).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Whitespace_inside_token_is_trimmed()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot> { ["A"] = Good(42) };
|
||||
MessageTemplate.Resolve("{ A }", p => Resolver(map, p)).ShouldBe("42");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure state-machine tests — no engine, no I/O, no async. Every transition rule
|
||||
/// from Phase 7 plan Stream C.2 / C.3 has at least one locking test so regressions
|
||||
/// surface as clear failures rather than subtle alarm-behavior drift.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
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);
|
||||
|
||||
[Fact]
|
||||
public void Predicate_true_on_inactive_becomes_active_and_emits_Activated()
|
||||
{
|
||||
var r = Part9StateMachine.ApplyPredicate(Fresh(), predicateTrue: true, T0.AddSeconds(1));
|
||||
r.State.Active.ShouldBe(AlarmActiveState.Active);
|
||||
r.State.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||
r.State.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
|
||||
r.Emission.ShouldBe(EmissionKind.Activated);
|
||||
r.State.LastActiveUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Predicate_false_on_active_becomes_inactive_and_emits_Cleared()
|
||||
{
|
||||
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
|
||||
var r = Part9StateMachine.ApplyPredicate(active, false, T0.AddSeconds(2));
|
||||
r.State.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
r.Emission.ShouldBe(EmissionKind.Cleared);
|
||||
r.State.LastClearedUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Predicate_unchanged_state_emits_None()
|
||||
{
|
||||
var r = Part9StateMachine.ApplyPredicate(Fresh(), false, T0);
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_alarm_ignores_predicate()
|
||||
{
|
||||
var disabled = Part9StateMachine.ApplyDisable(Fresh(), "op1", T0.AddSeconds(1)).State;
|
||||
var r = Part9StateMachine.ApplyPredicate(disabled, true, T0.AddSeconds(2));
|
||||
r.State.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_from_unacked_records_user_and_emits()
|
||||
{
|
||||
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
|
||||
var r = Part9StateMachine.ApplyAcknowledge(active, "alice", "looking into it", T0.AddSeconds(2));
|
||||
r.State.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
r.State.LastAckUser.ShouldBe("alice");
|
||||
r.State.LastAckComment.ShouldBe("looking into it");
|
||||
r.State.Comments.Count.ShouldBe(1);
|
||||
r.Emission.ShouldBe(EmissionKind.Acknowledged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_when_already_acked_is_noop()
|
||||
{
|
||||
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
|
||||
var acked = Part9StateMachine.ApplyAcknowledge(active, "alice", null, T0.AddSeconds(2)).State;
|
||||
var r = Part9StateMachine.ApplyAcknowledge(acked, "alice", null, T0.AddSeconds(3));
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_without_user_throws()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
Part9StateMachine.ApplyAcknowledge(Fresh(), "", null, T0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Confirm_after_clear_records_user_and_emits()
|
||||
{
|
||||
// Walk: activate -> ack -> clear -> confirm
|
||||
var s = Fresh();
|
||||
s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)).State;
|
||||
s = Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)).State;
|
||||
s = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3)).State;
|
||||
|
||||
var r = Part9StateMachine.ApplyConfirm(s, "bob", "resolved", T0.AddSeconds(4));
|
||||
r.State.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
|
||||
r.State.LastConfirmUser.ShouldBe("bob");
|
||||
r.Emission.ShouldBe(EmissionKind.Confirmed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneShotShelve_suppresses_next_activation_emission()
|
||||
{
|
||||
var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0.AddSeconds(1)).State;
|
||||
var r = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2));
|
||||
r.State.Active.ShouldBe(AlarmActiveState.Active, "state still advances");
|
||||
r.Emission.ShouldBe(EmissionKind.Suppressed, "but subscribers don't see it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneShotShelve_expires_on_clear()
|
||||
{
|
||||
var s = Fresh();
|
||||
s = Part9StateMachine.ApplyOneShotShelve(s, "alice", T0.AddSeconds(1)).State;
|
||||
s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2)).State;
|
||||
var r = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3));
|
||||
r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved, "OneShot expires on clear");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimedShelve_requires_future_unshelve_time()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", T0, T0.AddSeconds(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimedShelve_expires_via_shelving_check()
|
||||
{
|
||||
var until = T0.AddMinutes(5);
|
||||
var shelved = Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", until, T0).State;
|
||||
shelved.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
|
||||
|
||||
// Before expiry — still shelved.
|
||||
var earlier = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(3));
|
||||
earlier.State.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
|
||||
earlier.Emission.ShouldBe(EmissionKind.None);
|
||||
|
||||
// After expiry — auto-unshelved + emission.
|
||||
var after = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(6));
|
||||
after.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||
after.Emission.ShouldBe(EmissionKind.Unshelved);
|
||||
after.State.Comments.Any(c => c.Kind == "AutoUnshelve").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unshelve_from_unshelved_is_noop()
|
||||
{
|
||||
var r = Part9StateMachine.ApplyUnshelve(Fresh(), "alice", T0);
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_Unshelve_emits_event()
|
||||
{
|
||||
var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0).State;
|
||||
var r = Part9StateMachine.ApplyUnshelve(s, "bob", T0.AddSeconds(30));
|
||||
r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||
r.Emission.ShouldBe(EmissionKind.Unshelved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddComment_appends_to_audit_trail_with_event()
|
||||
{
|
||||
var r = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "investigating", T0.AddSeconds(5));
|
||||
r.State.Comments.Count.ShouldBe(1);
|
||||
r.State.Comments[0].Kind.ShouldBe("AddComment");
|
||||
r.State.Comments[0].User.ShouldBe("alice");
|
||||
r.State.Comments[0].Text.ShouldBe("investigating");
|
||||
r.Emission.ShouldBe(EmissionKind.CommentAdded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comments_are_append_only_never_rewritten()
|
||||
{
|
||||
var s = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "first", T0.AddSeconds(1)).State;
|
||||
s = Part9StateMachine.ApplyAddComment(s, "bob", "second", T0.AddSeconds(2)).State;
|
||||
s = Part9StateMachine.ApplyAddComment(s, "carol", "third", T0.AddSeconds(3)).State;
|
||||
s.Comments.Count.ShouldBe(3);
|
||||
s.Comments[0].User.ShouldBe("alice");
|
||||
s.Comments[1].User.ShouldBe("bob");
|
||||
s.Comments[2].User.ShouldBe("carol");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Full_lifecycle_walk_produces_every_expected_emission()
|
||||
{
|
||||
// Walk a condition through its whole lifecycle and make sure emissions line up.
|
||||
var emissions = new List<EmissionKind>();
|
||||
var s = Fresh();
|
||||
|
||||
s = Capture(Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)));
|
||||
s = Capture(Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)));
|
||||
s = Capture(Part9StateMachine.ApplyAddComment(s, "alice", "need to investigate", T0.AddSeconds(3)));
|
||||
s = Capture(Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(4)));
|
||||
s = Capture(Part9StateMachine.ApplyConfirm(s, "bob", null, T0.AddSeconds(5)));
|
||||
|
||||
emissions.ShouldBe(new[] {
|
||||
EmissionKind.Activated,
|
||||
EmissionKind.Acknowledged,
|
||||
EmissionKind.CommentAdded,
|
||||
EmissionKind.Cleared,
|
||||
EmissionKind.Confirmed,
|
||||
});
|
||||
|
||||
AlarmConditionState Capture(TransitionResult r) { emissions.Add(r.Emission); return r.State; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end engine tests: load, predicate evaluation, change-triggered
|
||||
/// re-evaluation, state persistence, startup recovery, error isolation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmEngineTests
|
||||
{
|
||||
private static ScriptedAlarmEngine Build(FakeUpstream up, out IAlarmStateStore store)
|
||||
{
|
||||
store = new InMemoryAlarmStateStore();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
|
||||
}
|
||||
|
||||
private static ScriptedAlarmDefinition Alarm(string id, string predicate,
|
||||
string msg = "condition", AlarmSeverity sev = AlarmSeverity.High) =>
|
||||
new(AlarmId: id,
|
||||
EquipmentPath: "Plant/Line1/Reactor",
|
||||
AlarmName: id,
|
||||
Kind: AlarmKind.AlarmCondition,
|
||||
Severity: sev,
|
||||
MessageTemplate: msg,
|
||||
PredicateScriptSource: predicate);
|
||||
|
||||
[Fact]
|
||||
public async Task Load_compiles_and_subscribes_to_referenced_upstreams()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
|
||||
await eng.LoadAsync([Alarm("a1", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
eng.LoadedAlarmIds.ShouldContain("a1");
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compile_failures_aggregated_into_one_error()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var eng = Build(up, out _);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await eng.LoadAsync([
|
||||
Alarm("bad1", "return unknownIdentifier;"),
|
||||
Alarm("good", "return true;"),
|
||||
Alarm("bad2", "var x = alsoUnknown; return x;"),
|
||||
], TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("2 alarm(s) did not compile");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upstream_change_re_evaluates_predicate_and_emits_Activated()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await WaitForAsync(() => events.Count > 0);
|
||||
|
||||
events[0].AlarmId.ShouldBe("HighTemp");
|
||||
events[0].Emission.ShouldBe(EmissionKind.Activated);
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clearing_upstream_emits_Cleared_event()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Startup sees 150 → active.
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 50);
|
||||
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Cleared));
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Message_template_resolves_tag_values_at_emission()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
up.Set("Limit", 100);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([
|
||||
new ScriptedAlarmDefinition(
|
||||
"HighTemp", "Plant/Line1", "HighTemp",
|
||||
AlarmKind.LimitAlarm, AlarmSeverity.High,
|
||||
"Temp {Temp}C exceeded limit {Limit}C",
|
||||
"""return (int)ctx.GetTag("Temp").Value > (int)ctx.GetTag("Limit").Value;"""),
|
||||
], TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await WaitForAsync(() => events.Any());
|
||||
|
||||
events[0].Message.ShouldBe("Temp 150C exceeded limit 100C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ack_records_user_and_persists_to_store()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
using var eng = Build(up, out var store);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await eng.AcknowledgeAsync("HighTemp", "alice", "checking", TestContext.Current.CancellationToken);
|
||||
|
||||
var persisted = await store.LoadAsync("HighTemp", TestContext.Current.CancellationToken);
|
||||
persisted.ShouldNotBeNull();
|
||||
persisted!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
persisted.LastAckUser.ShouldBe("alice");
|
||||
persisted.LastAckComment.ShouldBe("checking");
|
||||
persisted.Comments.Any(c => c.Kind == "Acknowledge" && c.User == "alice").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_recovery_preserves_ack_but_rederives_active_from_predicate()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50); // predicate will go false on second load
|
||||
|
||||
// First run — alarm goes active + operator acks.
|
||||
using (var eng1 = Build(up, out var sharedStore))
|
||||
{
|
||||
up.Set("Temp", 150);
|
||||
await eng1.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
eng1.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
|
||||
await eng1.AcknowledgeAsync("HighTemp", "alice", null, TestContext.Current.CancellationToken);
|
||||
eng1.GetState("HighTemp")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
}
|
||||
|
||||
// Simulate restart — temp is back to 50 (below threshold).
|
||||
up.Set("Temp", 50);
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var store2 = new InMemoryAlarmStateStore();
|
||||
// seed store2 with the acked state from before restart
|
||||
await store2.SaveAsync(new AlarmConditionState(
|
||||
"HighTemp",
|
||||
AlarmEnabledState.Enabled,
|
||||
AlarmActiveState.Active, // was active pre-restart
|
||||
AlarmAckedState.Acknowledged, // ack persisted
|
||||
AlarmConfirmedState.Unconfirmed,
|
||||
ShelvingState.Unshelved,
|
||||
DateTime.UtcNow,
|
||||
DateTime.UtcNow, null,
|
||||
DateTime.UtcNow, "alice", null,
|
||||
null, null, null,
|
||||
[new AlarmComment(DateTime.UtcNow, "alice", "Acknowledge", "")]),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng2 = new ScriptedAlarmEngine(up, store2, new ScriptLoggerFactory(logger), logger);
|
||||
await eng2.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var s = eng2.GetState("HighTemp")!;
|
||||
s.Active.ShouldBe(AlarmActiveState.Inactive, "Active recomputed from current tag value");
|
||||
s.Acked.ShouldBe(AlarmAckedState.Acknowledged, "Ack persisted across restart");
|
||||
s.LastAckUser.ShouldBe("alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shelved_active_transitions_state_but_suppresses_emission()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await eng.OneShotShelveAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Any(e => e.Emission == EmissionKind.Activated).ShouldBeFalse(
|
||||
"OneShot shelve suppresses activation emission");
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active,
|
||||
"state still advances so startup recovery is consistent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Predicate_runtime_exception_does_not_transition_state()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([
|
||||
Alarm("BadScript", """throw new InvalidOperationException("boom");"""),
|
||||
Alarm("GoodScript", """return (int)ctx.GetTag("Temp").Value > 100;"""),
|
||||
], TestContext.Current.CancellationToken);
|
||||
|
||||
// Bad script doesn't activate + doesn't disable other alarms.
|
||||
eng.GetState("BadScript")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
eng.GetState("GoodScript")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disable_prevents_activation_until_re_enabled()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await eng.DisableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(100);
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive,
|
||||
"disabled alarm ignores predicate");
|
||||
|
||||
await eng.EnableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
|
||||
up.Push("Temp", 160);
|
||||
await WaitForAsync(() => eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddComment_appends_to_audit_without_state_change()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out var store);
|
||||
await eng.LoadAsync([Alarm("A", """return false;""")], TestContext.Current.CancellationToken);
|
||||
|
||||
await eng.AddCommentAsync("A", "alice", "peeking at this", TestContext.Current.CancellationToken);
|
||||
|
||||
var s = await store.LoadAsync("A", TestContext.Current.CancellationToken);
|
||||
s.ShouldNotBeNull();
|
||||
s!.Comments.Count.ShouldBe(1);
|
||||
s.Comments[0].User.ShouldBe("alice");
|
||||
s.Comments[0].Kind.ShouldBe("AddComment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Predicate_scripts_cannot_SetVirtualTag()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 100);
|
||||
using var eng = Build(up, out _);
|
||||
|
||||
// The script compiles fine but throws at runtime when SetVirtualTag is called.
|
||||
// The engine swallows the exception + leaves state unchanged.
|
||||
await eng.LoadAsync([
|
||||
new ScriptedAlarmDefinition(
|
||||
"Bad", "Plant/Line1", "Bad",
|
||||
AlarmKind.AlarmCondition, AlarmSeverity.High, "bad",
|
||||
"""
|
||||
ctx.SetVirtualTag("NotAllowed", 1);
|
||||
return true;
|
||||
"""),
|
||||
], TestContext.Current.CancellationToken);
|
||||
|
||||
// Bad alarm's predicate threw — state unchanged.
|
||||
eng.GetState("Bad")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_releases_upstream_subscriptions()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
eng.Dispose();
|
||||
up.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (cond()) return;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
throw new TimeoutException("Condition did not become true in time");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmSourceTests
|
||||
{
|
||||
private static async Task<(ScriptedAlarmEngine e, ScriptedAlarmSource s, FakeUpstream u)> BuildAsync()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var engine = new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(),
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
await engine.LoadAsync([
|
||||
new ScriptedAlarmDefinition(
|
||||
"Plant/Line1::HighTemp",
|
||||
"Plant/Line1",
|
||||
"HighTemp",
|
||||
AlarmKind.LimitAlarm,
|
||||
AlarmSeverity.High,
|
||||
"Temp {Temp}C",
|
||||
"""return (int)ctx.GetTag("Temp").Value > 100;"""),
|
||||
new ScriptedAlarmDefinition(
|
||||
"Plant/Line2::OtherAlarm",
|
||||
"Plant/Line2",
|
||||
"OtherAlarm",
|
||||
AlarmKind.AlarmCondition,
|
||||
AlarmSeverity.Low,
|
||||
"other",
|
||||
"""return false;"""),
|
||||
], CancellationToken.None);
|
||||
|
||||
var source = new ScriptedAlarmSource(engine);
|
||||
return (engine, source, up);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_with_empty_filter_receives_every_alarm_emission()
|
||||
{
|
||||
var (engine, source, up) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].ConditionId.ShouldBe("Plant/Line1::HighTemp");
|
||||
events[0].SourceNodeId.ShouldBe("Plant/Line1");
|
||||
events[0].Severity.ShouldBe(AlarmSeverity.High);
|
||||
events[0].AlarmType.ShouldBe("LimitAlarm");
|
||||
events[0].Message.ShouldBe("Temp 150C");
|
||||
|
||||
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_with_equipment_prefix_filters_by_that_prefix()
|
||||
{
|
||||
var (engine, source, up) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
|
||||
// Subscribe only to Line1 alarms.
|
||||
var handle = await source.SubscribeAlarmsAsync(["Plant/Line1"], TestContext.Current.CancellationToken);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].SourceNodeId.ShouldBe("Plant/Line1");
|
||||
|
||||
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_stops_further_events()
|
||||
{
|
||||
var (engine, source, up) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
||||
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
||||
{
|
||||
var (engine, source, up) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(200);
|
||||
engine.GetState("Plant/Line1::HighTemp")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||
|
||||
await source.AcknowledgeAsync([new AlarmAcknowledgeRequest(
|
||||
"Plant/Line1", "Plant/Line1::HighTemp", "ack via opcua")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var state = engine.GetState("Plant/Line1::HighTemp")!;
|
||||
state.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
state.LastAckUser.ShouldBe("opcua-client");
|
||||
state.LastAckComment.ShouldBe("ack via opcua");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_arguments_rejected()
|
||||
{
|
||||
var (engine, source, _) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.SubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.UnsubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.AcknowledgeAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,151 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the source-hash keyed compile cache. Roslyn compilation is the most
|
||||
/// expensive step in the evaluator pipeline; this cache collapses redundant
|
||||
/// compiles of unchanged scripts to zero-cost lookups + makes sure concurrent
|
||||
/// callers never double-compile.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CompiledScriptCacheTests
|
||||
{
|
||||
private sealed class CompileCountingGate
|
||||
{
|
||||
public int Count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_call_compiles_and_caches()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.Count.ShouldBe(0);
|
||||
|
||||
var e = cache.GetOrCompile("""return 42;""");
|
||||
e.ShouldNotBeNull();
|
||||
cache.Count.ShouldBe(1);
|
||||
cache.Contains("""return 42;""").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identical_source_returns_the_same_compiled_evaluator()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var first = cache.GetOrCompile("""return 1;""");
|
||||
var second = cache.GetOrCompile("""return 1;""");
|
||||
ReferenceEquals(first, second).ShouldBeTrue();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_source_produces_different_evaluator()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var a = cache.GetOrCompile("""return 1;""");
|
||||
var b = cache.GetOrCompile("""return 2;""");
|
||||
ReferenceEquals(a, b).ShouldBeFalse();
|
||||
cache.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Whitespace_difference_misses_cache()
|
||||
{
|
||||
// Documented behavior: reformatting a script recompiles. Simpler + cheaper
|
||||
// than the alternative (AST-canonicalize then hash) and doesn't happen often.
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.GetOrCompile("""return 1;""");
|
||||
cache.GetOrCompile("return 1; "); // trailing whitespace — different hash
|
||||
cache.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cached_evaluator_still_runs_correctly()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, double>();
|
||||
var e = cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""");
|
||||
var ctx = new FakeScriptContext().Seed("In", 7.0);
|
||||
|
||||
// Run twice through the cache — both must return the same correct value.
|
||||
var first = await e.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
var second = await cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""")
|
||||
.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
first.ShouldBe(21.0);
|
||||
second.ShouldBe(21.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_compile_is_evicted_so_retry_with_corrected_source_works()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
|
||||
// First attempt — undefined identifier, compile throws.
|
||||
Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
|
||||
cache.Count.ShouldBe(0, "failed compile must be evicted so retry can re-attempt");
|
||||
|
||||
// Retry with corrected source succeeds + caches.
|
||||
cache.GetOrCompile("""return 42;""").ShouldNotBeNull();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_drops_every_entry()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.GetOrCompile("""return 1;""");
|
||||
cache.GetOrCompile("""return 2;""");
|
||||
cache.Count.ShouldBe(2);
|
||||
|
||||
cache.Clear();
|
||||
cache.Count.ShouldBe(0);
|
||||
cache.Contains("""return 1;""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concurrent_compiles_of_the_same_source_deduplicate()
|
||||
{
|
||||
// LazyThreadSafetyMode.ExecutionAndPublication guarantees only one compile
|
||||
// even when multiple threads race GetOrCompile against an empty cache.
|
||||
// We can't directly count Roslyn compilations — but we can assert all
|
||||
// concurrent callers see the same evaluator instance.
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
const string src = """return 99;""";
|
||||
|
||||
var tasks = Enumerable.Range(0, 20)
|
||||
.Select(_ => Task.Run(() => cache.GetOrCompile(src)))
|
||||
.ToArray();
|
||||
Task.WhenAll(tasks).GetAwaiter().GetResult();
|
||||
|
||||
var firstInstance = tasks[0].Result;
|
||||
foreach (var t in tasks)
|
||||
ReferenceEquals(t.Result, firstInstance).ShouldBeTrue();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_TContext_TResult_pairs_use_separate_cache_instances()
|
||||
{
|
||||
// Documented: each engine (virtual-tag / alarm-predicate / alarm-action) owns
|
||||
// its own cache. The type-parametric design makes this the default without
|
||||
// cross-contamination at the dictionary level.
|
||||
var intCache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var boolCache = new CompiledScriptCache<FakeScriptContext, bool>();
|
||||
|
||||
intCache.GetOrCompile("""return 1;""");
|
||||
boolCache.GetOrCompile("""return true;""");
|
||||
|
||||
intCache.Count.ShouldBe(1);
|
||||
boolCache.Count.ShouldBe(1);
|
||||
intCache.Contains("""return true;""").ShouldBeFalse();
|
||||
boolCache.Contains("""return 1;""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_source_throws_ArgumentNullException()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the AST walker that extracts static tag dependencies from user scripts
|
||||
/// + rejects every form of non-literal path. Locks the parse shape the virtual-tag
|
||||
/// engine's change-trigger scheduler will depend on (Phase 7 plan Stream A.2).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DependencyExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Extracts_single_literal_read()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("Line1/Speed").Value;""");
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldContain("Line1/Speed");
|
||||
result.Writes.ShouldBeEmpty();
|
||||
result.Rejections.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extracts_multiple_distinct_reads()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var a = ctx.GetTag("Line1/A").Value;
|
||||
var b = ctx.GetTag("Line1/B").Value;
|
||||
return (double)a + (double)b;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(2);
|
||||
result.Reads.ShouldContain("Line1/A");
|
||||
result.Reads.ShouldContain("Line1/B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deduplicates_identical_reads_across_the_script()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
if (((double)ctx.GetTag("X").Value) > 0)
|
||||
return ctx.GetTag("X").Value;
|
||||
return 0;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(1);
|
||||
result.Reads.ShouldContain("X");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracks_virtual_tag_writes_separately_from_reads()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var v = (double)ctx.GetTag("InTag").Value;
|
||||
ctx.SetVirtualTag("OutTag", v * 2);
|
||||
return v;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldContain("InTag");
|
||||
result.Writes.ShouldContain("OutTag");
|
||||
result.Reads.ShouldNotContain("OutTag");
|
||||
result.Writes.ShouldNotContain("InTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_variable_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var path = "Line1/Speed";
|
||||
return ctx.GetTag(path).Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections.Count.ShouldBe(1);
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_concatenated_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("Line1/" + "Speed").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_interpolated_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var n = 1;
|
||||
return ctx.GetTag($"Line{n}/Speed").Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_method_returned_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
string BuildPath() => "Line1/Speed";
|
||||
return ctx.GetTag(BuildPath()).Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_empty_literal_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_whitespace_only_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag(" ").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ignores_non_ctx_method_named_GetTag()
|
||||
{
|
||||
// Scripts are free to define their own helper called "GetTag" — as long as it's
|
||||
// not on the ctx instance, the extractor doesn't pick it up. The sandbox
|
||||
// compile will still reject any path that isn't on the ScriptContext type.
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
string helper_GetTag(string p) => p;
|
||||
return helper_GetTag("NotATag");
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_source_is_a_no_op()
|
||||
{
|
||||
DependencyExtractor.Extract("").IsValid.ShouldBeTrue();
|
||||
DependencyExtractor.Extract(" ").IsValid.ShouldBeTrue();
|
||||
DependencyExtractor.Extract(null!).IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejection_carries_source_span_for_UI_pointing()
|
||||
{
|
||||
// Offending path at column 23-29 in the source — Admin UI uses Span to
|
||||
// underline the exact token.
|
||||
const string src = """return ctx.GetTag(path).Value;""";
|
||||
var result = DependencyExtractor.Extract(src);
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Span.Start.ShouldBeGreaterThan(0);
|
||||
result.Rejections[0].Span.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_bad_paths_all_reported_in_one_pass()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var p1 = "A"; var p2 = "B";
|
||||
return ctx.GetTag(p1).Value.ToString() + ctx.GetTag(p2).Value.ToString();
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nested_literal_GetTag_inside_expression_is_extracted()
|
||||
{
|
||||
// Supports patterns like ctx.GetTag("A") > ctx.GetTag("B") — both literal args
|
||||
// are captured even when the enclosing expression is complex.
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
return ((double)ctx.GetTag("A").Value) > ((double)ctx.GetTag("B").Value);
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="ScriptContext"/> for tests. Holds a tag dictionary + a write
|
||||
/// log + a deterministic clock. Concrete subclasses in production will wire
|
||||
/// GetTag/SetVirtualTag through the virtual-tag engine + driver dispatch; here they
|
||||
/// hit a plain dictionary.
|
||||
/// </summary>
|
||||
public sealed class FakeScriptContext : ScriptContext
|
||||
{
|
||||
public Dictionary<string, DataValueSnapshot> Tags { get; } = new(StringComparer.Ordinal);
|
||||
public List<(string Path, object? Value)> Writes { get; } = [];
|
||||
|
||||
public override DateTime Now { get; } = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
public override ILogger Logger { get; } = new LoggerConfiguration().CreateLogger();
|
||||
|
||||
public override DataValueSnapshot GetTag(string path)
|
||||
{
|
||||
return Tags.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, Now); // BadNodeIdUnknown
|
||||
}
|
||||
|
||||
public override void SetVirtualTag(string path, object? value)
|
||||
{
|
||||
Writes.Add((path, value));
|
||||
}
|
||||
|
||||
public FakeScriptContext Seed(string path, object? value,
|
||||
uint statusCode = 0u, DateTime? sourceTs = null)
|
||||
{
|
||||
Tags[path] = new DataValueSnapshot(value, statusCode, sourceTs ?? Now, Now);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the sink that mirrors script Error+ events to the main log at Warning
|
||||
/// level. Ensures script noise (Debug/Info/Warning) doesn't reach the main log
|
||||
/// while genuine script failures DO surface there so operators see them without
|
||||
/// watching a separate log file.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptLogCompanionSinkTests
|
||||
{
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
|
||||
private static (ILogger script, CapturingSink scriptSink, CapturingSink mainSink) BuildPipeline()
|
||||
{
|
||||
// Main logger captures companion forwards.
|
||||
var mainSink = new CapturingSink();
|
||||
var mainLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
|
||||
|
||||
// Script logger fans out to scripts file (here: capture sink) + the companion sink.
|
||||
var scriptSink = new CapturingSink();
|
||||
var scriptLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(scriptSink)
|
||||
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger))
|
||||
.CreateLogger();
|
||||
|
||||
return (scriptLogger, scriptSink, mainSink);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Info_event_lands_in_scripts_sink_but_not_in_main()
|
||||
{
|
||||
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Information("just info");
|
||||
|
||||
scriptSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warning_event_lands_in_scripts_sink_but_not_in_main()
|
||||
{
|
||||
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Warning("just a warning");
|
||||
|
||||
scriptSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_event_mirrored_to_main_at_Warning_level()
|
||||
{
|
||||
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "MyAlarm")
|
||||
.Error("condition script failed");
|
||||
|
||||
scriptSink.Events[0].Level.ShouldBe(LogEventLevel.Error);
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning, "Error+ is downgraded to Warning in the main log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mirrored_event_includes_ScriptName_and_original_level()
|
||||
{
|
||||
var (script, _, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "HighTemp")
|
||||
.Error("temp exceeded limit");
|
||||
|
||||
var forwarded = mainSink.Events[0];
|
||||
forwarded.Properties.ShouldContainKey("ScriptName");
|
||||
((ScalarValue)forwarded.Properties["ScriptName"]).Value.ShouldBe("HighTemp");
|
||||
forwarded.Properties.ShouldContainKey("OriginalLevel");
|
||||
((ScalarValue)forwarded.Properties["OriginalLevel"]).Value.ShouldBe(LogEventLevel.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mirrored_event_preserves_exception_for_main_log_stack_trace()
|
||||
{
|
||||
var (script, _, mainSink) = BuildPipeline();
|
||||
var ex = new InvalidOperationException("user code threw");
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "BadScript").Error(ex, "boom");
|
||||
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events[0].Exception.ShouldBeSameAs(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fatal_event_mirrored_just_like_Error()
|
||||
{
|
||||
var (script, _, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Fatal_Script").Fatal("catastrophic");
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_ScriptName_property_falls_back_to_unknown()
|
||||
{
|
||||
var (_, _, mainSink) = BuildPipeline();
|
||||
// Log without the ScriptName property to simulate a direct root-logger call
|
||||
// that bypassed the factory (defensive — shouldn't normally happen).
|
||||
var mainLogger = new LoggerConfiguration().CreateLogger();
|
||||
var companion = new ScriptLogCompanionSink(Log.Logger);
|
||||
|
||||
// Build an event manually so we can omit the property.
|
||||
var ev = new LogEvent(
|
||||
timestamp: DateTimeOffset.UtcNow,
|
||||
level: LogEventLevel.Error,
|
||||
exception: null,
|
||||
messageTemplate: new Serilog.Parsing.MessageTemplateParser().Parse("naked error"),
|
||||
properties: []);
|
||||
// Direct test: sink should not throw + message should be well-formed.
|
||||
Should.NotThrow(() => companion.Emit(ev));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_main_logger_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptLogCompanionSink(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_mirror_threshold_applied()
|
||||
{
|
||||
// Caller can raise the mirror threshold to Fatal if they want only
|
||||
// catastrophic events in the main log.
|
||||
var mainSink = new CapturingSink();
|
||||
var mainLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
|
||||
|
||||
var scriptLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger, LogEventLevel.Fatal))
|
||||
.CreateLogger();
|
||||
|
||||
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Error("error");
|
||||
mainSink.Events.Count.ShouldBe(0, "Error below configured Fatal threshold — not mirrored");
|
||||
|
||||
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Fatal("fatal");
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the factory that creates per-script Serilog loggers with the
|
||||
/// <c>ScriptName</c> structured property pre-bound. The property is what lets
|
||||
/// Admin UI filter the scripts-*.log sink by which tag/alarm emitted each event.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptLoggerFactoryTests
|
||||
{
|
||||
/// <summary>Capturing sink that collects every emitted LogEvent for assertion.</summary>
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_sets_ScriptName_structured_property()
|
||||
{
|
||||
var sink = new CapturingSink();
|
||||
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
var logger = factory.Create("LineRate");
|
||||
logger.Information("hello");
|
||||
|
||||
sink.Events.Count.ShouldBe(1);
|
||||
var ev = sink.Events[0];
|
||||
ev.Properties.ShouldContainKey(ScriptLoggerFactory.ScriptNameProperty);
|
||||
((ScalarValue)ev.Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("LineRate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_script_gets_its_own_property_value()
|
||||
{
|
||||
var sink = new CapturingSink();
|
||||
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
factory.Create("Alarm_A").Information("event A");
|
||||
factory.Create("Tag_B").Warning("event B");
|
||||
factory.Create("Alarm_A").Error("event A again");
|
||||
|
||||
sink.Events.Count.ShouldBe(3);
|
||||
((ScalarValue)sink.Events[0].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
|
||||
((ScalarValue)sink.Events[1].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Tag_B");
|
||||
((ScalarValue)sink.Events[2].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_level_event_preserves_level_and_exception()
|
||||
{
|
||||
var sink = new CapturingSink();
|
||||
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
factory.Create("Test").Error(new InvalidOperationException("boom"), "script failed");
|
||||
|
||||
sink.Events[0].Level.ShouldBe(LogEventLevel.Error);
|
||||
sink.Events[0].Exception.ShouldBeOfType<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_root_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptLoggerFactory(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_script_name_rejected()
|
||||
{
|
||||
var root = new LoggerConfiguration().CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
Should.Throw<ArgumentException>(() => factory.Create(""));
|
||||
Should.Throw<ArgumentException>(() => factory.Create(" "));
|
||||
Should.Throw<ArgumentException>(() => factory.Create(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptNameProperty_constant_is_stable()
|
||||
{
|
||||
// Stability is an external contract — the Admin UI's log filter references
|
||||
// this exact string. If it changes, the filter breaks silently.
|
||||
ScriptLoggerFactory.ScriptNameProperty.ShouldBe("ScriptName");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles scripts against the Phase 7 sandbox + asserts every forbidden API
|
||||
/// (HttpClient / File / Process / reflection) fails at compile, not at evaluation.
|
||||
/// Locks decision #6 — scripts can't escape to the broader .NET surface.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptSandboxTests
|
||||
{
|
||||
[Fact]
|
||||
public void Happy_path_script_compiles_and_returns()
|
||||
{
|
||||
// Baseline — ctx + Math + basic types must work.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||
"""
|
||||
var v = (double)ctx.GetTag("X").Value;
|
||||
return Math.Abs(v) * 2.0;
|
||||
""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Happy_path_script_runs_and_reads_seeded_tag()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||
"""return (double)ctx.GetTag("In").Value * 2.0;""");
|
||||
|
||||
var ctx = new FakeScriptContext().Seed("In", 21.0);
|
||||
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
result.ShouldBe(42.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_records_the_write()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
ctx.SetVirtualTag("Out", 42);
|
||||
return 0;
|
||||
""");
|
||||
var ctx = new FakeScriptContext();
|
||||
await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
ctx.Writes.Count.ShouldBe(1);
|
||||
ctx.Writes[0].Path.ShouldBe("Out");
|
||||
ctx.Writes[0].Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_File_IO_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, string>.Compile(
|
||||
"""return System.IO.File.ReadAllText("c:/secrets.txt");"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_HttpClient_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var c = new System.Net.Http.HttpClient();
|
||||
return 0;
|
||||
"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Process_Start_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
System.Diagnostics.Process.Start("cmd.exe");
|
||||
return 0;
|
||||
"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Reflection_Assembly_Load_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
System.Reflection.Assembly.Load("System.Core");
|
||||
return 0;
|
||||
"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Environment_GetEnvironmentVariable_at_compile()
|
||||
{
|
||||
// Environment lives in System.Private.CoreLib (allow-listed for primitives) —
|
||||
// BUT calling .GetEnvironmentVariable exposes process state we don't want in
|
||||
// scripts. In an allow-list sandbox this passes because mscorlib is allowed;
|
||||
// relying on ScriptSandbox alone isn't enough for the Environment class. We
|
||||
// document here that the CURRENT sandbox allows Environment — acceptable because
|
||||
// Environment doesn't leak outside the process boundary, doesn't side-effect
|
||||
// persistent state, and Phase 7 plan decision #6 targets File/Net/Process/
|
||||
// reflection specifically.
|
||||
//
|
||||
// This test LOCKS that compromise: operators should not be surprised if a
|
||||
// script reads an env var. If we later decide to tighten, this test flips.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, string?>.Compile(
|
||||
"""return System.Environment.GetEnvironmentVariable("PATH");""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_exception_propagates_unwrapped()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""throw new InvalidOperationException("boom");""");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock()
|
||||
{
|
||||
// Scripts that need a timestamp go through ctx.Now so tests can pin it.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, DateTime>.Compile("""return ctx.Now;""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deadband_helper_is_reachable_from_scripts()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
|
||||
"""return ScriptContext.Deadband(10.5, 10.0, 0.3);""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Linq_Enumerable_is_available_from_scripts()
|
||||
{
|
||||
// LINQ is in the allow-list because SCADA math frequently wants Sum / Average
|
||||
// / Where. Confirm it works.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var nums = new[] { 1, 2, 3, 4, 5 };
|
||||
return nums.Where(n => n > 2).Sum();
|
||||
""");
|
||||
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
|
||||
result.ShouldBe(12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataValueSnapshot_is_usable_in_scripts()
|
||||
{
|
||||
// ctx.GetTag returns DataValueSnapshot so scripts branch on quality.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
|
||||
"""
|
||||
var v = ctx.GetTag("T");
|
||||
return v.StatusCode == 0;
|
||||
""");
|
||||
var ctx = new FakeScriptContext().Seed("T", 5.0);
|
||||
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_error_gives_location_in_diagnostics()
|
||||
{
|
||||
// Compile errors must carry the source span so the Admin UI can point at them.
|
||||
try
|
||||
{
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile("""return fooBarBaz + 1;""");
|
||||
Assert.Fail("expected CompilationErrorException");
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
ex.Diagnostics.ShouldNotBeEmpty();
|
||||
ex.Diagnostics[0].Location.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the per-evaluation timeout wrapper. Fast scripts complete normally;
|
||||
/// CPU-bound or hung scripts throw <see cref="ScriptTimeoutException"/> instead of
|
||||
/// starving the engine. Caller-supplied cancellation tokens take precedence over the
|
||||
/// timeout so driver-shutdown paths see a clean cancel rather than a timeout.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TimedScriptEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Fast_script_completes_under_timeout_and_returns_value()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||
"""return (double)ctx.GetTag("In").Value + 1.0;""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, double>(
|
||||
inner, TimeSpan.FromSeconds(1));
|
||||
|
||||
var ctx = new FakeScriptContext().Seed("In", 41.0);
|
||||
var result = await timed.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
result.ShouldBe(42.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_longer_than_timeout_throws_ScriptTimeoutException()
|
||||
{
|
||||
// Scripts can't easily do Thread.Sleep in the sandbox (System.Threading.Thread
|
||||
// is denied). But a tight CPU loop exceeds any short timeout.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 5000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Timeout.ShouldBe(TimeSpan.FromMilliseconds(50));
|
||||
ex.Message.ShouldContain("50.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Caller_cancellation_takes_precedence_over_timeout()
|
||||
{
|
||||
// A CPU-bound script that would otherwise timeout; external ct fires first.
|
||||
// Expected: OperationCanceledException (not ScriptTimeoutException) so shutdown
|
||||
// paths aren't misclassified as timeouts.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 10000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromSeconds(5));
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(80));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_timeout_is_250ms_per_plan()
|
||||
{
|
||||
TimedScriptEvaluator<FakeScriptContext, int>.DefaultTimeout
|
||||
.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_or_negative_timeout_is_rejected_at_construction()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.Zero));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromMilliseconds(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_inner_is_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_context_is_rejected()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner);
|
||||
Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await timed.RunAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_exception_propagates_unwrapped()
|
||||
{
|
||||
// User-thrown exceptions must come through as-is — NOT wrapped in
|
||||
// ScriptTimeoutException. The virtual-tag engine catches them per-tag and
|
||||
// maps to BadInternalError; conflating with timeout would lose that info.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""throw new InvalidOperationException("script boom");""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromSeconds(1));
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldBe("script boom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTimeoutException_message_points_at_diagnostic_path()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 5000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromMilliseconds(30));
|
||||
|
||||
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("ctx.Logger");
|
||||
ex.Message.ShouldContain("widening the timeout");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,104 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PermissionTrieCacheTests
|
||||
{
|
||||
private static PermissionTrie Trie(string cluster, long generation) => new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
GenerationId = generation,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void GetTrie_Empty_ReturnsNull()
|
||||
{
|
||||
new PermissionTrieCache().GetTrie("c1").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Install_ThenGet_RoundTrips()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 5));
|
||||
|
||||
cache.GetTrie("c1")!.GenerationId.ShouldBe(5);
|
||||
cache.CurrentGenerationId("c1").ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewGeneration_BecomesCurrent()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 1));
|
||||
cache.Install(Trie("c1", 2));
|
||||
|
||||
cache.CurrentGenerationId("c1").ShouldBe(2);
|
||||
cache.GetTrie("c1", 1).ShouldNotBeNull("prior generation retained for in-flight requests");
|
||||
cache.GetTrie("c1", 2).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutOfOrder_Install_DoesNotDowngrade_Current()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 3));
|
||||
cache.Install(Trie("c1", 1)); // late-arriving older generation
|
||||
|
||||
cache.CurrentGenerationId("c1").ShouldBe(3, "older generation must not become current");
|
||||
cache.GetTrie("c1", 1).ShouldNotBeNull("but older is still retrievable by explicit lookup");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_DropsCluster()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 1));
|
||||
cache.Install(Trie("c2", 1));
|
||||
|
||||
cache.Invalidate("c1");
|
||||
|
||||
cache.GetTrie("c1").ShouldBeNull();
|
||||
cache.GetTrie("c2").ShouldNotBeNull("sibling cluster unaffected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prune_RetainsMostRecent()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
for (var g = 1L; g <= 5; g++) cache.Install(Trie("c1", g));
|
||||
|
||||
cache.Prune("c1", keepLatest: 2);
|
||||
|
||||
cache.GetTrie("c1", 5).ShouldNotBeNull();
|
||||
cache.GetTrie("c1", 4).ShouldNotBeNull();
|
||||
cache.GetTrie("c1", 3).ShouldBeNull();
|
||||
cache.GetTrie("c1", 1).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prune_LessThanKeep_IsNoOp()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 1));
|
||||
cache.Install(Trie("c1", 2));
|
||||
|
||||
cache.Prune("c1", keepLatest: 10);
|
||||
|
||||
cache.CachedTrieCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterIsolation()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(Trie("c1", 1));
|
||||
cache.Install(Trie("c2", 9));
|
||||
|
||||
cache.CurrentGenerationId("c1").ShouldBe(1);
|
||||
cache.CurrentGenerationId("c2").ShouldBe(9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PermissionTrieTests
|
||||
{
|
||||
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
|
||||
new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}",
|
||||
GenerationId = 1,
|
||||
ClusterId = clusterId,
|
||||
LdapGroup = group,
|
||||
ScopeKind = scope,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
|
||||
new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
NamespaceId = ns,
|
||||
UnsAreaId = area,
|
||||
UnsLineId = line,
|
||||
EquipmentId = equip,
|
||||
TagId = tag,
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
private static NodeScope GalaxyTag(string cluster, string ns, string[] folders, string tag) =>
|
||||
new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
NamespaceId = ns,
|
||||
FolderSegments = folders,
|
||||
TagId = tag,
|
||||
Kind = NodeHierarchyKind.SystemPlatform,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ClusterLevelGrant_Cascades_ToEveryTag()
|
||||
{
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, scopeId: null, NodePermissions.Read) };
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
var matches = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
||||
["cn=ops"]);
|
||||
|
||||
matches.Count.ShouldBe(1);
|
||||
matches[0].PermissionFlags.ShouldBe(NodePermissions.Read);
|
||||
matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquipmentScope_DoesNotLeak_ToSibling()
|
||||
{
|
||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["eq-A"] = new(new[] { "ns", "area1", "line1", "eq-A" }),
|
||||
};
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.Read) };
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||
|
||||
var matchA = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-A", "tag1"), ["cn=ops"]);
|
||||
var matchB = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-B", "tag1"), ["cn=ops"]);
|
||||
|
||||
matchA.Count.ShouldBe(1);
|
||||
matchB.ShouldBeEmpty("grant at eq-A must not apply to sibling eq-B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiGroup_Union_OrsPermissionFlags()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("cn=readers", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
||||
Row("cn=writers", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
|
||||
};
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
var matches = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
||||
["cn=readers", "cn=writers"]);
|
||||
|
||||
matches.Count.ShouldBe(2);
|
||||
var combined = matches.Aggregate(NodePermissions.None, (acc, m) => acc | m.PermissionFlags);
|
||||
combined.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoMatchingGroup_ReturnsEmpty()
|
||||
{
|
||||
var rows = new[] { Row("cn=different", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
var matches = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
||||
["cn=ops"]);
|
||||
|
||||
matches.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Galaxy_FolderSegment_Grant_DoesNotLeak_To_Sibling_Folder()
|
||||
{
|
||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["folder-A"] = new(new[] { "ns-gal", "folder-A" }),
|
||||
};
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "folder-A", NodePermissions.Read) };
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||
|
||||
var matchA = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["folder-A"], "tag1"), ["cn=ops"]);
|
||||
var matchB = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["folder-B"], "tag1"), ["cn=ops"]);
|
||||
|
||||
matchA.Count.ShouldBe(1);
|
||||
matchB.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossCluster_Grant_DoesNotLeak()
|
||||
{
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read, clusterId: "c-other") };
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
var matches = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
|
||||
["cn=ops"]);
|
||||
|
||||
matches.ShouldBeEmpty("rows for cluster c-other must not land in c1's trie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsIdempotent()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row("cn=a", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
|
||||
Row("cn=b", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
|
||||
};
|
||||
|
||||
var trie1 = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
var trie2 = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
trie1.Root.Grants.Count.ShouldBe(trie2.Root.Grants.Count);
|
||||
trie1.ClusterId.ShouldBe(trie2.ClusterId);
|
||||
trie1.GenerationId.ShouldBe(trie2.GenerationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TriePermissionEvaluatorTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
private readonly FakeTimeProvider _time = new();
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
public DateTime Utc { get; set; } = Now;
|
||||
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags) =>
|
||||
new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}",
|
||||
GenerationId = 1,
|
||||
ClusterId = "c1",
|
||||
LdapGroup = group,
|
||||
ScopeKind = scope,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static UserAuthorizationState Session(string[] groups, DateTime? resolvedUtc = null, string clusterId = "c1") =>
|
||||
new()
|
||||
{
|
||||
SessionId = "sess",
|
||||
ClusterId = clusterId,
|
||||
LdapGroups = groups,
|
||||
MembershipResolvedUtc = resolvedUtc ?? Now,
|
||||
AuthGenerationId = 1,
|
||||
MembershipVersion = 1,
|
||||
};
|
||||
|
||||
private static NodeScope Scope(string cluster = "c1") =>
|
||||
new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
NamespaceId = "ns",
|
||||
UnsAreaId = "area",
|
||||
UnsLineId = "line",
|
||||
EquipmentId = "eq",
|
||||
TagId = "tag",
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
private TriePermissionEvaluator MakeEvaluator(NodeAcl[] rows)
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
|
||||
return new TriePermissionEvaluator(cache, _time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allow_When_RequiredFlag_Matched()
|
||||
{
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
|
||||
var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.Allow);
|
||||
decision.Provenance.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotGranted_When_NoMatchingGroup()
|
||||
{
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
|
||||
var decision = evaluator.Authorize(Session(["cn=unrelated"]), OpcUaOperation.Read, Scope());
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
decision.Provenance.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotGranted_When_FlagsInsufficient()
|
||||
{
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
|
||||
var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.WriteOperate, Scope());
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryRead_Requires_Its_Own_Bit()
|
||||
{
|
||||
// User has Read but not HistoryRead
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
|
||||
var liveRead = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
|
||||
var historyRead = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.HistoryRead, Scope());
|
||||
|
||||
liveRead.IsAllowed.ShouldBeTrue();
|
||||
historyRead.IsAllowed.ShouldBeFalse("HistoryRead uses its own NodePermissions flag, not Read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossCluster_Session_Denied()
|
||||
{
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
var otherSession = Session(["cn=ops"], clusterId: "c-other");
|
||||
|
||||
var decision = evaluator.Authorize(otherSession, OpcUaOperation.Read, Scope(cluster: "c1"));
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleSession_FailsClosed()
|
||||
{
|
||||
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
|
||||
var session = Session(["cn=ops"], resolvedUtc: Now);
|
||||
_time.Utc = Now.AddMinutes(10); // well past the 5-min AuthCacheMaxStaleness default
|
||||
|
||||
var decision = evaluator.Authorize(session, OpcUaOperation.Read, Scope());
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoCachedTrie_ForCluster_Denied()
|
||||
{
|
||||
var cache = new PermissionTrieCache(); // empty cache
|
||||
var evaluator = new TriePermissionEvaluator(cache, _time);
|
||||
|
||||
var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
|
||||
|
||||
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OperationToPermission_Mapping_IsTotal()
|
||||
{
|
||||
foreach (var op in Enum.GetValues<OpcUaOperation>())
|
||||
{
|
||||
// Must not throw — every OpcUaOperation needs a mapping or the compliance-check
|
||||
// "every operation wired" fails.
|
||||
TriePermissionEvaluator.MapOperationToPermission(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UserAuthorizationStateTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static UserAuthorizationState Fresh(DateTime resolved) => new()
|
||||
{
|
||||
SessionId = "s",
|
||||
ClusterId = "c1",
|
||||
LdapGroups = ["cn=ops"],
|
||||
MembershipResolvedUtc = resolved,
|
||||
AuthGenerationId = 1,
|
||||
MembershipVersion = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void FreshlyResolved_Is_NotStale_NorNeedsRefresh()
|
||||
{
|
||||
var session = Fresh(Now);
|
||||
|
||||
session.IsStale(Now.AddMinutes(1)).ShouldBeFalse();
|
||||
session.NeedsRefresh(Now.AddMinutes(1)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeedsRefresh_FiresAfter_FreshnessInterval()
|
||||
{
|
||||
var session = Fresh(Now);
|
||||
|
||||
session.NeedsRefresh(Now.AddMinutes(16)).ShouldBeFalse("past freshness but also past the 5-min staleness ceiling — should be Stale, not NeedsRefresh");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeedsRefresh_TrueBetween_Freshness_And_Staleness_Windows()
|
||||
{
|
||||
// Custom: freshness=2 min, staleness=10 min → between 2 and 10 min NeedsRefresh fires.
|
||||
var session = Fresh(Now) with
|
||||
{
|
||||
MembershipFreshnessInterval = TimeSpan.FromMinutes(2),
|
||||
AuthCacheMaxStaleness = TimeSpan.FromMinutes(10),
|
||||
};
|
||||
|
||||
session.NeedsRefresh(Now.AddMinutes(5)).ShouldBeTrue();
|
||||
session.IsStale(Now.AddMinutes(5)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsStale_TrueAfter_StalenessWindow()
|
||||
{
|
||||
var session = Fresh(Now);
|
||||
|
||||
session.IsStale(Now.AddMinutes(6)).ShouldBeTrue("default AuthCacheMaxStaleness is 5 min");
|
||||
}
|
||||
}
|
||||
80
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs
Normal file
80
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverHostTests
|
||||
{
|
||||
private sealed class StubDriver(string id, bool failInit = false) : IDriver
|
||||
{
|
||||
public string DriverInstanceId { get; } = id;
|
||||
public string DriverType => "Stub";
|
||||
public bool Initialized { get; private set; }
|
||||
public bool ShutDown { get; private set; }
|
||||
|
||||
public Task InitializeAsync(string _, CancellationToken ct)
|
||||
{
|
||||
if (failInit) throw new InvalidOperationException("boom");
|
||||
Initialized = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) { ShutDown = true; return Task.CompletedTask; }
|
||||
public DriverHealth GetHealth() =>
|
||||
new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_initializes_driver_and_tracks_health()
|
||||
{
|
||||
await using var host = new DriverHost();
|
||||
var driver = new StubDriver("d-1");
|
||||
|
||||
await host.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
|
||||
host.RegisteredDriverIds.ShouldContain("d-1");
|
||||
driver.Initialized.ShouldBeTrue();
|
||||
host.GetHealth("d-1")!.State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_rethrows_init_failure_but_keeps_driver_registered()
|
||||
{
|
||||
await using var host = new DriverHost();
|
||||
var driver = new StubDriver("d-bad", failInit: true);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
host.RegisterAsync(driver, "{}", CancellationToken.None));
|
||||
|
||||
host.RegisteredDriverIds.ShouldContain("d-bad");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_registration_throws()
|
||||
{
|
||||
await using var host = new DriverHost();
|
||||
await host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unregister_shuts_down_and_removes()
|
||||
{
|
||||
await using var host = new DriverHost();
|
||||
var driver = new StubDriver("d-1");
|
||||
await host.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
|
||||
await host.UnregisterAsync("d-1", CancellationToken.None);
|
||||
|
||||
host.RegisteredDriverIds.ShouldNotContain("d-1");
|
||||
driver.ShutDown.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GenericDriverNodeManagerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// BuildAddressSpaceAsync walks the driver's discovery through the caller's builder. Every
|
||||
/// variable marked with MarkAsAlarmCondition captures its sink in the node manager; later,
|
||||
/// IAlarmSource.OnAlarmEvent payloads are routed by SourceNodeId to the matching sink.
|
||||
/// This is the plumbing that PR 16's concrete OPC UA builder will use to update the actual
|
||||
/// AlarmConditionState nodes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Alarm_events_are_routed_to_the_sink_registered_for_the_matching_source_node_id()
|
||||
{
|
||||
var driver = new FakeDriver();
|
||||
var builder = new RecordingBuilder();
|
||||
using var nm = new GenericDriverNodeManager(driver);
|
||||
|
||||
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Alarms.Count.ShouldBe(2);
|
||||
nm.TrackedAlarmSources.Count.ShouldBe(2);
|
||||
|
||||
// Simulate the driver raising a transition for one of the alarms.
|
||||
var args = new AlarmEventArgs(
|
||||
SubscriptionHandle: new FakeHandle("s1"),
|
||||
SourceNodeId: "Tank.HiHi",
|
||||
ConditionId: "cond-1",
|
||||
AlarmType: "Tank.HiHi",
|
||||
Message: "Level exceeded",
|
||||
Severity: AlarmSeverity.High,
|
||||
SourceTimestampUtc: DateTime.UtcNow);
|
||||
driver.RaiseAlarm(args);
|
||||
|
||||
builder.Alarms["Tank.HiHi"].Received.Count.ShouldBe(1);
|
||||
builder.Alarms["Tank.HiHi"].Received[0].Message.ShouldBe("Level exceeded");
|
||||
// The other alarm sink never received a payload — fan-out is tag-scoped.
|
||||
builder.Alarms["Heater.OverTemp"].Received.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_alarm_variables_do_not_register_sinks()
|
||||
{
|
||||
var driver = new FakeDriver();
|
||||
var builder = new RecordingBuilder();
|
||||
using var nm = new GenericDriverNodeManager(driver);
|
||||
|
||||
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
||||
|
||||
// FakeDriver registers 2 alarm-bearing variables + 1 plain variable.
|
||||
nm.TrackedAlarmSources.ShouldNotContain("Tank.Level"); // the plain one
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_source_node_id_is_dropped_silently()
|
||||
{
|
||||
var driver = new FakeDriver();
|
||||
var builder = new RecordingBuilder();
|
||||
using var nm = new GenericDriverNodeManager(driver);
|
||||
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
||||
|
||||
driver.RaiseAlarm(new AlarmEventArgs(
|
||||
new FakeHandle("s1"), "Unknown.Source", "c", "t", "m", AlarmSeverity.Low, DateTime.UtcNow));
|
||||
|
||||
builder.Alarms.Values.All(s => s.Received.Count == 0).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_unsubscribes_from_OnAlarmEvent()
|
||||
{
|
||||
var driver = new FakeDriver();
|
||||
var builder = new RecordingBuilder();
|
||||
var nm = new GenericDriverNodeManager(driver);
|
||||
await nm.BuildAddressSpaceAsync(builder, CancellationToken.None);
|
||||
|
||||
nm.Dispose();
|
||||
|
||||
driver.RaiseAlarm(new AlarmEventArgs(
|
||||
new FakeHandle("s1"), "Tank.HiHi", "c", "t", "m", AlarmSeverity.Low, DateTime.UtcNow));
|
||||
|
||||
// No sink should have received it — the forwarder was detached.
|
||||
builder.Alarms["Tank.HiHi"].Received.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// --- test doubles ---
|
||||
|
||||
private sealed class FakeDriver : IDriver, ITagDiscovery, IAlarmSource
|
||||
{
|
||||
public string DriverInstanceId => "fake";
|
||||
public string DriverType => "Fake";
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
var folder = builder.Folder("Tank", "Tank");
|
||||
var lvl = folder.Variable("Level", "Level", new DriverAttributeInfo(
|
||||
"Tank.Level", DriverDataType.Float64, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
|
||||
var hiHi = folder.Variable("HiHi", "HiHi", new DriverAttributeInfo(
|
||||
"Tank.HiHi", DriverDataType.Boolean, false, null, SecurityClassification.FreeAccess, false, IsAlarm: true));
|
||||
hiHi.MarkAsAlarmCondition(new AlarmConditionInfo("Tank.HiHi", AlarmSeverity.High, "High-high alarm"));
|
||||
|
||||
var heater = builder.Folder("Heater", "Heater");
|
||||
var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo(
|
||||
"Heater.OverTemp", DriverDataType.Boolean, false, null, SecurityClassification.FreeAccess, false, IsAlarm: true));
|
||||
ot.MarkAsAlarmCondition(new AlarmConditionInfo("Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature"));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(IReadOnlyList<string> _, CancellationToken __)
|
||||
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __) => Task.CompletedTask;
|
||||
public Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId { get; } = diagnosticId;
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public Dictionary<string, RecordingSink> Alarms { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IAddressSpaceBuilder Folder(string _, string __) => this;
|
||||
|
||||
public IVariableHandle Variable(string _, string __, DriverAttributeInfo info)
|
||||
=> new Handle(info.FullName, Alarms);
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
public sealed class Handle(string fullRef, Dictionary<string, RecordingSink> alarms) : IVariableHandle
|
||||
{
|
||||
public string FullReference { get; } = fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo _)
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
alarms[FullReference] = sink;
|
||||
return sink;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RecordingSink : IAlarmConditionSink
|
||||
{
|
||||
public List<AlarmEventArgs> Received { get; } = new();
|
||||
public void OnTransition(AlarmEventArgs args) => Received.Add(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Observability;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class CapabilityInvokerEnrichmentTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvokerExecute_LogsInsideCallSite_CarryStructuredProperties()
|
||||
{
|
||||
var sink = new InMemorySink();
|
||||
var logger = new LoggerConfiguration()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
var invoker = new CapabilityInvoker(
|
||||
new DriverResiliencePipelineBuilder(),
|
||||
driverInstanceId: "drv-live",
|
||||
optionsAccessor: () => new DriverResilienceOptions { Tier = DriverTier.A },
|
||||
driverType: "Modbus");
|
||||
|
||||
await invoker.ExecuteAsync(
|
||||
DriverCapability.Read,
|
||||
"plc-1",
|
||||
ct =>
|
||||
{
|
||||
logger.Information("inside call site");
|
||||
return ValueTask.FromResult(42);
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
var evt = sink.Events.ShouldHaveSingleItem();
|
||||
evt.Properties["DriverInstanceId"].ToString().ShouldBe("\"drv-live\"");
|
||||
evt.Properties["DriverType"].ToString().ShouldBe("\"Modbus\"");
|
||||
evt.Properties["CapabilityName"].ToString().ShouldBe("\"Read\"");
|
||||
evt.Properties.ShouldContainKey("CorrelationId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokerExecute_DoesNotLeak_ContextOutsideCallSite()
|
||||
{
|
||||
var sink = new InMemorySink();
|
||||
var logger = new LoggerConfiguration()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
var invoker = new CapabilityInvoker(
|
||||
new DriverResiliencePipelineBuilder(),
|
||||
driverInstanceId: "drv-a",
|
||||
optionsAccessor: () => new DriverResilienceOptions { Tier = DriverTier.A });
|
||||
|
||||
await invoker.ExecuteAsync(DriverCapability.Read, "host", _ => ValueTask.FromResult(1), CancellationToken.None);
|
||||
logger.Information("outside");
|
||||
|
||||
var outside = sink.Events.ShouldHaveSingleItem();
|
||||
outside.Properties.ContainsKey("DriverInstanceId").ShouldBeFalse();
|
||||
}
|
||||
|
||||
private sealed class InMemorySink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Observability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Observability;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverHealthReportTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyFleet_IsHealthy()
|
||||
{
|
||||
DriverHealthReport.Aggregate([]).ShouldBe(ReadinessVerdict.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllHealthy_Fleet_IsHealthy()
|
||||
{
|
||||
var verdict = DriverHealthReport.Aggregate([
|
||||
new DriverHealthSnapshot("a", DriverState.Healthy),
|
||||
new DriverHealthSnapshot("b", DriverState.Healthy),
|
||||
]);
|
||||
verdict.ShouldBe(ReadinessVerdict.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyFaulted_TrumpsEverything()
|
||||
{
|
||||
var verdict = DriverHealthReport.Aggregate([
|
||||
new DriverHealthSnapshot("a", DriverState.Healthy),
|
||||
new DriverHealthSnapshot("b", DriverState.Degraded),
|
||||
new DriverHealthSnapshot("c", DriverState.Faulted),
|
||||
new DriverHealthSnapshot("d", DriverState.Initializing),
|
||||
]);
|
||||
verdict.ShouldBe(ReadinessVerdict.Faulted);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverState.Unknown)]
|
||||
[InlineData(DriverState.Initializing)]
|
||||
public void Any_NotReady_WithoutFaulted_IsNotReady(DriverState initializingState)
|
||||
{
|
||||
var verdict = DriverHealthReport.Aggregate([
|
||||
new DriverHealthSnapshot("a", DriverState.Healthy),
|
||||
new DriverHealthSnapshot("b", initializingState),
|
||||
]);
|
||||
verdict.ShouldBe(ReadinessVerdict.NotReady);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Any_Degraded_WithoutFaultedOrNotReady_IsDegraded()
|
||||
{
|
||||
var verdict = DriverHealthReport.Aggregate([
|
||||
new DriverHealthSnapshot("a", DriverState.Healthy),
|
||||
new DriverHealthSnapshot("b", DriverState.Degraded),
|
||||
]);
|
||||
verdict.ShouldBe(ReadinessVerdict.Degraded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ReadinessVerdict.Healthy, 200)]
|
||||
[InlineData(ReadinessVerdict.Degraded, 200)]
|
||||
[InlineData(ReadinessVerdict.NotReady, 503)]
|
||||
[InlineData(ReadinessVerdict.Faulted, 503)]
|
||||
public void HttpStatus_MatchesStateMatrix(ReadinessVerdict verdict, int expected)
|
||||
{
|
||||
DriverHealthReport.HttpStatus(verdict).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Observability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Observability;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LogContextEnricherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Scope_Attaches_AllFour_Properties()
|
||||
{
|
||||
var captured = new InMemorySink();
|
||||
var logger = new LoggerConfiguration()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Sink(captured)
|
||||
.CreateLogger();
|
||||
|
||||
using (LogContextEnricher.Push("drv-1", "Modbus", DriverCapability.Read, "abc123"))
|
||||
{
|
||||
logger.Information("test message");
|
||||
}
|
||||
|
||||
var evt = captured.Events.ShouldHaveSingleItem();
|
||||
evt.Properties["DriverInstanceId"].ToString().ShouldBe("\"drv-1\"");
|
||||
evt.Properties["DriverType"].ToString().ShouldBe("\"Modbus\"");
|
||||
evt.Properties["CapabilityName"].ToString().ShouldBe("\"Read\"");
|
||||
evt.Properties["CorrelationId"].ToString().ShouldBe("\"abc123\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scope_Dispose_Pops_Properties()
|
||||
{
|
||||
var captured = new InMemorySink();
|
||||
var logger = new LoggerConfiguration()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Sink(captured)
|
||||
.CreateLogger();
|
||||
|
||||
using (LogContextEnricher.Push("drv-1", "Modbus", DriverCapability.Read, "abc123"))
|
||||
{
|
||||
logger.Information("inside");
|
||||
}
|
||||
logger.Information("outside");
|
||||
|
||||
captured.Events.Count.ShouldBe(2);
|
||||
captured.Events[0].Properties.ContainsKey("DriverInstanceId").ShouldBeTrue();
|
||||
captured.Events[1].Properties.ContainsKey("DriverInstanceId").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewCorrelationId_Returns_12_Hex_Chars()
|
||||
{
|
||||
var id = LogContextEnricher.NewCorrelationId();
|
||||
id.Length.ShouldBe(12);
|
||||
id.ShouldMatch("^[0-9a-f]{12}$");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Push_Throws_OnMissingDriverInstanceId(string? id)
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
LogContextEnricher.Push(id!, "Modbus", DriverCapability.Read, "c"));
|
||||
}
|
||||
|
||||
private sealed class InMemorySink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentNodeWalkerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Walk_EmptyContent_EmitsNothing()
|
||||
{
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], []));
|
||||
|
||||
rec.Children.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder()
|
||||
{
|
||||
var content = new EquipmentNamespaceContent(
|
||||
Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")],
|
||||
Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")],
|
||||
Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")],
|
||||
Tags: []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]); // ordered by Name
|
||||
var warsaw = rec.Children.First(c => c.BrowseName == "warsaw");
|
||||
warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]);
|
||||
warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid()
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.EquipmentUuid = uuid;
|
||||
eq.MachineCode = "MC-42";
|
||||
eq.ZTag = null;
|
||||
eq.SAPID = null;
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList();
|
||||
props.ShouldContain("EquipmentId");
|
||||
props.ShouldContain("EquipmentUuid");
|
||||
props.ShouldContain("MachineCode");
|
||||
props.ShouldNotContain("ZTag");
|
||||
props.ShouldNotContain("SAPID");
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Adds_ZTag_And_SAPID_When_Present()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.ZTag = "ZT-0042";
|
||||
eq.SAPID = "10000042";
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042");
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.Manufacturer = "Trumpf";
|
||||
eq.Model = "TruLaser-3030";
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification");
|
||||
identification.ShouldNotBeNull();
|
||||
identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer");
|
||||
identification.Properties.Select(p => p.BrowseName).ShouldContain("Model");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1");
|
||||
var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1");
|
||||
var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [tag1, tag2, unboundTag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Variables.Count.ShouldBe(2);
|
||||
equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]);
|
||||
equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01");
|
||||
equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_FallsBack_To_String_For_Unparseable_DataType()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var variable = rec.Children[0].Children[0].Children[0].Variables.Single();
|
||||
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var vtag = new VirtualTag
|
||||
{
|
||||
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "LineRate",
|
||||
DataType = "Float32", ScriptId = "scr-1", Historize = true,
|
||||
};
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [], VirtualTags: [vtag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var v = equipmentNode.Variables.Single(x => x.BrowseName == "LineRate");
|
||||
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Virtual);
|
||||
v.AttributeInfo.VirtualTagId.ShouldBe("vt-1");
|
||||
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
||||
v.AttributeInfo.IsHistorized.ShouldBeTrue();
|
||||
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "HighTemp",
|
||||
AlarmType = "LimitAlarm", MessageTemplate = "{Temp} exceeded",
|
||||
PredicateScriptId = "scr-9", Severity = 800,
|
||||
};
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [], ScriptedAlarms: [alarm]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var v = rec.Children[0].Children[0].Children[0].Variables.Single(x => x.BrowseName == "HighTemp");
|
||||
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.ScriptedAlarm);
|
||||
v.AttributeInfo.ScriptedAlarmId.ShouldBe("al-1");
|
||||
v.AttributeInfo.VirtualTagId.ShouldBeNull();
|
||||
v.AttributeInfo.IsAlarm.ShouldBeTrue();
|
||||
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Skips_Disabled_VirtualTags_And_Alarms()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var vtag = new VirtualTag
|
||||
{
|
||||
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled",
|
||||
DataType = "Float32", ScriptId = "scr-1", Enabled = false,
|
||||
};
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "DisabledAlarm",
|
||||
AlarmType = "LimitAlarm", MessageTemplate = "x",
|
||||
PredicateScriptId = "scr-9", Enabled = false,
|
||||
};
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [], VirtualTags: [vtag], ScriptedAlarms: [alarm]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe()
|
||||
{
|
||||
// Backwards-compat — callers that don't populate the new collections still work.
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content); // must not throw
|
||||
|
||||
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_tag_default_NodeSourceKind_is_Driver()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [tag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var v = rec.Children[0].Children[0].Children[0].Variables.Single();
|
||||
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Driver);
|
||||
v.AttributeInfo.VirtualTagId.ShouldBeNull();
|
||||
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFullName_unwraps_json_object_with_FullName_field()
|
||||
{
|
||||
EquipmentNodeWalker.ExtractFullName(
|
||||
"{\"FullName\":\"MESReceiver_001.MoveInBatchID\",\"DataType\":\"Int32\"}")
|
||||
.ShouldBe("MESReceiver_001.MoveInBatchID");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFullName_handles_S7_style_extra_fields()
|
||||
{
|
||||
EquipmentNodeWalker.ExtractFullName(
|
||||
"{\"FullName\":\"DB1_DBW0\",\"Address\":\"DB1.DBW0\",\"DataType\":\"Int16\"}")
|
||||
.ShouldBe("DB1_DBW0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFullName_returns_raw_when_not_json()
|
||||
{
|
||||
// Drivers that opt out of JSON TagConfig still work — fallback preserves the literal
|
||||
// string so the driver's IReadable sees whatever the row author stored.
|
||||
EquipmentNodeWalker.ExtractFullName("raw-tag-ref").ShouldBe("raw-tag-ref");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFullName_returns_raw_when_json_missing_FullName_field()
|
||||
{
|
||||
EquipmentNodeWalker.ExtractFullName("{\"Address\":\"DB1.DBW0\"}")
|
||||
.ShouldBe("{\"Address\":\"DB1.DBW0\"}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_tag_FullName_passes_through_from_TagConfig_json()
|
||||
{
|
||||
// The walker hands the driver the unwrapped FullName string so IReadable.ReadAsync
|
||||
// sees the plain address, not the raw TagConfig JSON. Verifies the dispatch contract
|
||||
// the path-based NodeId refactor relies on.
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1",
|
||||
tagConfig: "{\"FullName\":\"plc-01/HR200\",\"DataType\":\"Int32\"}");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [tag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var v = rec.Children[0].Children[0].Children[0].Variables.Single();
|
||||
v.AttributeInfo.FullName.ShouldBe("plc-01/HR200");
|
||||
}
|
||||
|
||||
// ----- builders for test seed rows -----
|
||||
|
||||
private static UnsArea Area(string id, string name) => new()
|
||||
{
|
||||
UnsAreaId = id, ClusterId = "c1", Name = name, GenerationId = 1,
|
||||
};
|
||||
|
||||
private static UnsLine Line(string id, string areaId, string name) => new()
|
||||
{
|
||||
UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1,
|
||||
};
|
||||
|
||||
private static Equipment Eq(string equipmentId, string lineId, string name) => new()
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(),
|
||||
GenerationId = 1,
|
||||
EquipmentId = equipmentId,
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = "drv",
|
||||
UnsLineId = lineId,
|
||||
Name = name,
|
||||
MachineCode = "MC-" + name,
|
||||
};
|
||||
|
||||
private static Tag NewTag(string tagId, string name, string dataType, string address,
|
||||
string? equipmentId, string? tagConfig = null) => new()
|
||||
{
|
||||
TagRowId = Guid.NewGuid(),
|
||||
GenerationId = 1,
|
||||
TagId = tagId,
|
||||
DriverInstanceId = "drv",
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = dataType,
|
||||
AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite,
|
||||
TagConfig = tagConfig ?? address,
|
||||
};
|
||||
|
||||
// ----- recording IAddressSpaceBuilder -----
|
||||
|
||||
private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder
|
||||
{
|
||||
public string BrowseName { get; } = browseName;
|
||||
public List<RecordingBuilder> Children { get; } = new();
|
||||
public List<RecordingVariable> Variables { get; } = new();
|
||||
public List<RecordingProperty> Properties { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string name, string _)
|
||||
{
|
||||
var child = new RecordingBuilder(name);
|
||||
Children.Add(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
|
||||
{
|
||||
var v = new RecordingVariable(name, attr);
|
||||
Variables.Add(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
public void AddProperty(string name, DriverDataType _, object? value) =>
|
||||
Properties.Add(new RecordingProperty(name, value));
|
||||
}
|
||||
|
||||
private sealed record RecordingProperty(string BrowseName, object? Value);
|
||||
|
||||
private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle
|
||||
{
|
||||
public string FullReference => AttributeInfo.FullName;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class IdentificationFolderBuilderTests
|
||||
{
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
public List<(string BrowseName, DriverDataType DataType, object? Value)> Properties { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add((browseName, displayName));
|
||||
return this; // flat recording — identification fields land in the same bucket
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> throw new NotSupportedException("Identification fields use AddProperty, not Variable");
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
=> Properties.Add((browseName, dataType, value));
|
||||
}
|
||||
|
||||
private static Equipment EmptyEquipment() => new()
|
||||
{
|
||||
EquipmentId = "EQ-000000000001",
|
||||
DriverInstanceId = "drv-1",
|
||||
UnsLineId = "line-1",
|
||||
Name = "eq-1",
|
||||
MachineCode = "machine_001",
|
||||
};
|
||||
|
||||
private static Equipment FullyPopulatedEquipment() => new()
|
||||
{
|
||||
EquipmentId = "EQ-000000000001",
|
||||
DriverInstanceId = "drv-1",
|
||||
UnsLineId = "line-1",
|
||||
Name = "eq-1",
|
||||
MachineCode = "machine_001",
|
||||
Manufacturer = "Siemens",
|
||||
Model = "S7-1500",
|
||||
SerialNumber = "SN-12345",
|
||||
HardwareRevision = "Rev-A",
|
||||
SoftwareRevision = "Fw-2.3.1",
|
||||
YearOfConstruction = 2023,
|
||||
AssetLocation = "Warsaw-West/Bldg-3",
|
||||
ManufacturerUri = "https://siemens.example",
|
||||
DeviceManualUri = "https://siemens.example/manual",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void HasAnyFields_AllNull_ReturnsFalse()
|
||||
{
|
||||
IdentificationFolderBuilder.HasAnyFields(EmptyEquipment()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAnyFields_OneNonNull_ReturnsTrue()
|
||||
{
|
||||
var eq = EmptyEquipment();
|
||||
eq.SerialNumber = "SN-1";
|
||||
IdentificationFolderBuilder.HasAnyFields(eq).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AllNull_ReturnsNull_AndDoesNotEmit_Folder()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
|
||||
var result = IdentificationFolderBuilder.Build(builder, EmptyEquipment());
|
||||
|
||||
result.ShouldBeNull();
|
||||
builder.Folders.ShouldBeEmpty("no Identification folder when every field is null");
|
||||
builder.Properties.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FullyPopulated_EmitsAllNineFields()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
|
||||
var result = IdentificationFolderBuilder.Build(builder, FullyPopulatedEquipment());
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Identification");
|
||||
builder.Properties.Count.ShouldBe(9);
|
||||
builder.Properties.Select(p => p.BrowseName).ShouldBe(
|
||||
["Manufacturer", "Model", "SerialNumber",
|
||||
"HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation",
|
||||
"ManufacturerUri", "DeviceManualUri"],
|
||||
"property order matches decision #139 exactly");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_OnlyNonNull_Are_Emitted()
|
||||
{
|
||||
var eq = EmptyEquipment();
|
||||
eq.Manufacturer = "Siemens";
|
||||
eq.SerialNumber = "SN-1";
|
||||
eq.YearOfConstruction = 2024;
|
||||
var builder = new RecordingBuilder();
|
||||
|
||||
IdentificationFolderBuilder.Build(builder, eq);
|
||||
|
||||
builder.Properties.Count.ShouldBe(3, "only the 3 non-null fields are exposed");
|
||||
builder.Properties.Select(p => p.BrowseName).ShouldBe(
|
||||
["Manufacturer", "SerialNumber", "YearOfConstruction"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void YearOfConstruction_Maps_Short_To_Int32_DriverDataType()
|
||||
{
|
||||
var eq = EmptyEquipment();
|
||||
eq.YearOfConstruction = 2023;
|
||||
var builder = new RecordingBuilder();
|
||||
|
||||
IdentificationFolderBuilder.Build(builder, eq);
|
||||
|
||||
var prop = builder.Properties.Single(p => p.BrowseName == "YearOfConstruction");
|
||||
prop.DataType.ShouldBe(DriverDataType.Int32);
|
||||
prop.Value.ShouldBe(2023, "short is widened to int for OPC UA Int32 representation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_StringValues_RoundTrip()
|
||||
{
|
||||
var eq = FullyPopulatedEquipment();
|
||||
var builder = new RecordingBuilder();
|
||||
|
||||
IdentificationFolderBuilder.Build(builder, eq);
|
||||
|
||||
builder.Properties.Single(p => p.BrowseName == "Manufacturer").Value.ShouldBe("Siemens");
|
||||
builder.Properties.Single(p => p.BrowseName == "DeviceManualUri").Value.ShouldBe("https://siemens.example/manual");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldNames_Match_Decision139_Exactly()
|
||||
{
|
||||
IdentificationFolderBuilder.FieldNames.ShouldBe(
|
||||
["Manufacturer", "Model", "SerialNumber",
|
||||
"HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation",
|
||||
"ManufacturerUri", "DeviceManualUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FolderName_Is_Identification()
|
||||
{
|
||||
IdentificationFolderBuilder.FolderName.ShouldBe("Identification");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AlarmSurfaceInvokerTests
|
||||
{
|
||||
private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A };
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_EmptyList_ReturnsEmpty_WithoutDriverCall()
|
||||
{
|
||||
var driver = new FakeAlarmSource();
|
||||
var surface = NewSurface(driver, defaultHost: "h");
|
||||
|
||||
var handles = await surface.SubscribeAsync([], CancellationToken.None);
|
||||
|
||||
handles.Count.ShouldBe(0);
|
||||
driver.SubscribeCallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_SingleHost_RoutesThroughDefaultHost()
|
||||
{
|
||||
var driver = new FakeAlarmSource();
|
||||
var surface = NewSurface(driver, defaultHost: "h1");
|
||||
|
||||
var handles = await surface.SubscribeAsync(["src-1", "src-2"], CancellationToken.None);
|
||||
|
||||
handles.Count.ShouldBe(1);
|
||||
driver.SubscribeCallCount.ShouldBe(1);
|
||||
driver.LastSubscribedIds.ShouldBe(["src-1", "src-2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_MultiHost_FansOutByResolvedHost()
|
||||
{
|
||||
var driver = new FakeAlarmSource();
|
||||
var resolver = new StubResolver(new Dictionary<string, string>
|
||||
{
|
||||
["src-1"] = "plc-a",
|
||||
["src-2"] = "plc-b",
|
||||
["src-3"] = "plc-a",
|
||||
});
|
||||
var surface = NewSurface(driver, defaultHost: "default-ignored", resolver: resolver);
|
||||
|
||||
var handles = await surface.SubscribeAsync(["src-1", "src-2", "src-3"], CancellationToken.None);
|
||||
|
||||
handles.Count.ShouldBe(2); // one per distinct host
|
||||
driver.SubscribeCallCount.ShouldBe(2); // one driver call per host
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_DoesNotRetry_OnFailure()
|
||||
{
|
||||
var driver = new FakeAlarmSource { AcknowledgeShouldThrow = true };
|
||||
var surface = NewSurface(driver, defaultHost: "h1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
surface.AcknowledgeAsync([new AlarmAcknowledgeRequest("s", "c", null)], CancellationToken.None));
|
||||
|
||||
driver.AcknowledgeCallCount.ShouldBe(1, "AlarmAcknowledge must not retry — decision #143");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_Retries_Transient_Failures()
|
||||
{
|
||||
var driver = new FakeAlarmSource { SubscribeFailuresBeforeSuccess = 2 };
|
||||
var surface = NewSurface(driver, defaultHost: "h1");
|
||||
|
||||
await surface.SubscribeAsync(["src"], CancellationToken.None);
|
||||
|
||||
driver.SubscribeCallCount.ShouldBe(3, "AlarmSubscribe retries by default — decision #143");
|
||||
}
|
||||
|
||||
private static AlarmSurfaceInvoker NewSurface(
|
||||
IAlarmSource driver,
|
||||
string defaultHost,
|
||||
IPerCallHostResolver? resolver = null)
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var invoker = new CapabilityInvoker(builder, "drv-1", () => TierAOptions);
|
||||
return new AlarmSurfaceInvoker(invoker, driver, defaultHost, resolver);
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmSource : IAlarmSource
|
||||
{
|
||||
public int SubscribeCallCount { get; private set; }
|
||||
public int AcknowledgeCallCount { get; private set; }
|
||||
public int SubscribeFailuresBeforeSuccess { get; set; }
|
||||
public bool AcknowledgeShouldThrow { get; set; }
|
||||
public IReadOnlyList<string> LastSubscribedIds { get; private set; } = [];
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
SubscribeCallCount++;
|
||||
LastSubscribedIds = sourceNodeIds;
|
||||
if (SubscribeCallCount <= SubscribeFailuresBeforeSuccess)
|
||||
throw new InvalidOperationException("transient");
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(new StubHandle($"h-{SubscribeCallCount}"));
|
||||
}
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
AcknowledgeCallCount++;
|
||||
if (AcknowledgeShouldThrow) throw new InvalidOperationException("ack boom");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent { add { } remove { } }
|
||||
}
|
||||
|
||||
private sealed record StubHandle(string DiagnosticId) : IAlarmSubscriptionHandle;
|
||||
|
||||
private sealed class StubResolver(Dictionary<string, string> map) : IPerCallHostResolver
|
||||
{
|
||||
public string ResolveHost(string fullReference) => map[fullReference];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CapabilityInvokerTests
|
||||
{
|
||||
private static CapabilityInvoker MakeInvoker(
|
||||
DriverResiliencePipelineBuilder builder,
|
||||
DriverResilienceOptions options) =>
|
||||
new(builder, "drv-test", () => options);
|
||||
|
||||
[Fact]
|
||||
public async Task Read_ReturnsValue_FromCallSite()
|
||||
{
|
||||
var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), new DriverResilienceOptions { Tier = DriverTier.A });
|
||||
|
||||
var result = await invoker.ExecuteAsync(
|
||||
DriverCapability.Read,
|
||||
"host-1",
|
||||
_ => ValueTask.FromResult(42),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Retries_OnTransientFailure()
|
||||
{
|
||||
var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), new DriverResilienceOptions { Tier = DriverTier.A });
|
||||
var attempts = 0;
|
||||
|
||||
var result = await invoker.ExecuteAsync(
|
||||
DriverCapability.Read,
|
||||
"host-1",
|
||||
async _ =>
|
||||
{
|
||||
attempts++;
|
||||
if (attempts < 2) throw new InvalidOperationException("transient");
|
||||
await Task.Yield();
|
||||
return "ok";
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe("ok");
|
||||
attempts.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_NonIdempotent_DoesNotRetry_EvenWhenPolicyHasRetries()
|
||||
{
|
||||
var options = new DriverResilienceOptions
|
||||
{
|
||||
Tier = DriverTier.A,
|
||||
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
||||
{
|
||||
[DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5),
|
||||
},
|
||||
};
|
||||
var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), options);
|
||||
var attempts = 0;
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await invoker.ExecuteWriteAsync(
|
||||
"host-1",
|
||||
isIdempotent: false,
|
||||
async _ =>
|
||||
{
|
||||
attempts++;
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("boom");
|
||||
#pragma warning disable CS0162
|
||||
return 0;
|
||||
#pragma warning restore CS0162
|
||||
},
|
||||
CancellationToken.None));
|
||||
|
||||
attempts.ShouldBe(1, "non-idempotent write must never replay");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_Idempotent_Retries_WhenPolicyHasRetries()
|
||||
{
|
||||
var options = new DriverResilienceOptions
|
||||
{
|
||||
Tier = DriverTier.A,
|
||||
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
||||
{
|
||||
[DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5),
|
||||
},
|
||||
};
|
||||
var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), options);
|
||||
var attempts = 0;
|
||||
|
||||
var result = await invoker.ExecuteWriteAsync(
|
||||
"host-1",
|
||||
isIdempotent: true,
|
||||
async _ =>
|
||||
{
|
||||
attempts++;
|
||||
if (attempts < 2) throw new InvalidOperationException("transient");
|
||||
await Task.Yield();
|
||||
return "ok";
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe("ok");
|
||||
attempts.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_Default_DoesNotRetry_WhenPolicyHasZeroRetries()
|
||||
{
|
||||
// Tier A Write default is RetryCount=0. Even isIdempotent=true shouldn't retry
|
||||
// because the policy says not to.
|
||||
var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), new DriverResilienceOptions { Tier = DriverTier.A });
|
||||
var attempts = 0;
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await invoker.ExecuteWriteAsync(
|
||||
"host-1",
|
||||
isIdempotent: true,
|
||||
async _ =>
|
||||
{
|
||||
attempts++;
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("boom");
|
||||
#pragma warning disable CS0162
|
||||
return 0;
|
||||
#pragma warning restore CS0162
|
||||
},
|
||||
CancellationToken.None));
|
||||
|
||||
attempts.ShouldBe(1, "tier-A default for Write is RetryCount=0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_HonorsDifferentHosts_Independently()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var invoker = MakeInvoker(builder, new DriverResilienceOptions { Tier = DriverTier.A });
|
||||
|
||||
await invoker.ExecuteAsync(DriverCapability.Read, "host-a", _ => ValueTask.FromResult(1), CancellationToken.None);
|
||||
await invoker.ExecuteAsync(DriverCapability.Read, "host-b", _ => ValueTask.FromResult(2), CancellationToken.None);
|
||||
|
||||
builder.CachedPipelineCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverResilienceOptionsParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void NullJson_ReturnsPureTierDefaults()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, null, out var diag);
|
||||
|
||||
diag.ShouldBeNull();
|
||||
options.Tier.ShouldBe(DriverTier.A);
|
||||
options.Resolve(DriverCapability.Read).ShouldBe(
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhitespaceJson_ReturnsDefaults()
|
||||
{
|
||||
DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, " ", out var diag);
|
||||
diag.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedJson_FallsBack_WithDiagnostic()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{not json", out var diag);
|
||||
|
||||
diag.ShouldNotBeNull();
|
||||
diag.ShouldContain("malformed");
|
||||
options.Tier.ShouldBe(DriverTier.A);
|
||||
options.Resolve(DriverCapability.Read).ShouldBe(
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyObject_ReturnsDefaults()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{}", out var diag);
|
||||
|
||||
diag.ShouldBeNull();
|
||||
options.Resolve(DriverCapability.Write).ShouldBe(
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOverride_MergedIntoTierDefaults()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"capabilityPolicies": {
|
||||
"Read": { "timeoutSeconds": 5, "retryCount": 7, "breakerFailureThreshold": 2 }
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
|
||||
|
||||
diag.ShouldBeNull();
|
||||
var read = options.Resolve(DriverCapability.Read);
|
||||
read.TimeoutSeconds.ShouldBe(5);
|
||||
read.RetryCount.ShouldBe(7);
|
||||
read.BreakerFailureThreshold.ShouldBe(2);
|
||||
|
||||
// Other capabilities untouched
|
||||
options.Resolve(DriverCapability.Write).ShouldBe(
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartialPolicy_FillsMissingFieldsFromTierDefault()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"capabilityPolicies": {
|
||||
"Read": { "retryCount": 10 }
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
|
||||
|
||||
var read = options.Resolve(DriverCapability.Read);
|
||||
var tierDefault = DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read];
|
||||
read.RetryCount.ShouldBe(10);
|
||||
read.TimeoutSeconds.ShouldBe(tierDefault.TimeoutSeconds, "partial override; timeout falls back to tier default");
|
||||
read.BreakerFailureThreshold.ShouldBe(tierDefault.BreakerFailureThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BulkheadOverrides_AreHonored()
|
||||
{
|
||||
var json = """
|
||||
{ "bulkheadMaxConcurrent": 100, "bulkheadMaxQueue": 500 }
|
||||
""";
|
||||
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, json, out _);
|
||||
|
||||
options.BulkheadMaxConcurrent.ShouldBe(100);
|
||||
options.BulkheadMaxQueue.ShouldBe(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownCapability_Surfaces_InDiagnostic_ButDoesNotFail()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"capabilityPolicies": {
|
||||
"InventedCapability": { "timeoutSeconds": 99 }
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
|
||||
|
||||
diag.ShouldNotBeNull();
|
||||
diag.ShouldContain("InventedCapability");
|
||||
// Known capabilities untouched.
|
||||
options.Resolve(DriverCapability.Read).ShouldBe(
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PropertyNames_AreCaseInsensitive()
|
||||
{
|
||||
var json = """
|
||||
{ "BULKHEADMAXCONCURRENT": 42 }
|
||||
""";
|
||||
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
|
||||
|
||||
options.BulkheadMaxConcurrent.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapabilityName_IsCaseInsensitive()
|
||||
{
|
||||
var json = """
|
||||
{ "capabilityPolicies": { "read": { "retryCount": 99 } } }
|
||||
""";
|
||||
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
|
||||
|
||||
diag.ShouldBeNull();
|
||||
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(99);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
[InlineData(DriverTier.C)]
|
||||
public void EveryTier_WithEmptyJson_RoundTrips_Its_Defaults(DriverTier tier)
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, "{}", out var diag);
|
||||
|
||||
diag.ShouldBeNull();
|
||||
options.Tier.ShouldBe(tier);
|
||||
foreach (var cap in Enum.GetValues<DriverCapability>())
|
||||
options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_TierC_PositiveValue_ParsesAndSurfaces()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
||||
DriverTier.C, "{\"recycleIntervalSeconds\":3600}", out var diag);
|
||||
|
||||
diag.ShouldBeNull();
|
||||
options.RecycleIntervalSeconds.ShouldBe(3600);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_Null_DefaultsToNull()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.C, "{}", out _);
|
||||
options.RecycleIntervalSeconds.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
public void RecycleIntervalSeconds_OnTierAorB_Rejected_With_Diagnostic(DriverTier tier)
|
||||
{
|
||||
// Decision #74 — in-process drivers must not scheduled-recycle because it would
|
||||
// tear down every OPC UA session. The parser surfaces a diagnostic rather than
|
||||
// silently honouring the value.
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
||||
tier, "{\"recycleIntervalSeconds\":3600}", out var diag);
|
||||
|
||||
options.RecycleIntervalSeconds.ShouldBeNull();
|
||||
diag.ShouldContain("Tier C only");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_NonPositive_Rejected_With_Diagnostic()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
||||
DriverTier.C, "{\"recycleIntervalSeconds\":0}", out var diag);
|
||||
|
||||
options.RecycleIntervalSeconds.ShouldBeNull();
|
||||
diag.ShouldContain("must be positive");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverResilienceOptionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
[InlineData(DriverTier.C)]
|
||||
public void TierDefaults_Cover_EveryCapability(DriverTier tier)
|
||||
{
|
||||
var defaults = DriverResilienceOptions.GetTierDefaults(tier);
|
||||
|
||||
foreach (var capability in Enum.GetValues<DriverCapability>())
|
||||
defaults.ShouldContainKey(capability);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
[InlineData(DriverTier.C)]
|
||||
public void Write_NeverRetries_ByDefault(DriverTier tier)
|
||||
{
|
||||
var defaults = DriverResilienceOptions.GetTierDefaults(tier);
|
||||
defaults[DriverCapability.Write].RetryCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
[InlineData(DriverTier.C)]
|
||||
public void AlarmAcknowledge_NeverRetries_ByDefault(DriverTier tier)
|
||||
{
|
||||
var defaults = DriverResilienceOptions.GetTierDefaults(tier);
|
||||
defaults[DriverCapability.AlarmAcknowledge].RetryCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A, DriverCapability.Read)]
|
||||
[InlineData(DriverTier.A, DriverCapability.HistoryRead)]
|
||||
[InlineData(DriverTier.B, DriverCapability.Discover)]
|
||||
[InlineData(DriverTier.B, DriverCapability.Probe)]
|
||||
[InlineData(DriverTier.C, DriverCapability.AlarmSubscribe)]
|
||||
public void IdempotentCapabilities_Retry_ByDefault(DriverTier tier, DriverCapability capability)
|
||||
{
|
||||
var defaults = DriverResilienceOptions.GetTierDefaults(tier);
|
||||
defaults[capability].RetryCount.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TierC_DisablesCircuitBreaker_DeferringToSupervisor()
|
||||
{
|
||||
var defaults = DriverResilienceOptions.GetTierDefaults(DriverTier.C);
|
||||
|
||||
foreach (var (_, policy) in defaults)
|
||||
policy.BreakerFailureThreshold.ShouldBe(0, "Tier C breaker is handled by the Proxy supervisor (decision #68)");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
public void TierAAndB_EnableCircuitBreaker(DriverTier tier)
|
||||
{
|
||||
var defaults = DriverResilienceOptions.GetTierDefaults(tier);
|
||||
|
||||
foreach (var (_, policy) in defaults)
|
||||
policy.BreakerFailureThreshold.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Uses_TierDefaults_When_NoOverride()
|
||||
{
|
||||
var options = new DriverResilienceOptions { Tier = DriverTier.A };
|
||||
|
||||
var resolved = options.Resolve(DriverCapability.Read);
|
||||
|
||||
resolved.ShouldBe(DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Uses_Override_When_Configured()
|
||||
{
|
||||
var custom = new CapabilityPolicy(TimeoutSeconds: 42, RetryCount: 7, BreakerFailureThreshold: 9);
|
||||
var options = new DriverResilienceOptions
|
||||
{
|
||||
Tier = DriverTier.A,
|
||||
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
||||
{
|
||||
[DriverCapability.Read] = custom,
|
||||
},
|
||||
};
|
||||
|
||||
options.Resolve(DriverCapability.Read).ShouldBe(custom);
|
||||
options.Resolve(DriverCapability.Write).ShouldBe(
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using Polly.CircuitBreaker;
|
||||
using Polly.Timeout;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverResiliencePipelineBuilderTests
|
||||
{
|
||||
private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A };
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Retries_Transient_Failures()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var pipeline = builder.GetOrCreate("drv-test", "host-1", DriverCapability.Read, TierAOptions);
|
||||
var attempts = 0;
|
||||
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
attempts++;
|
||||
if (attempts < 3) throw new InvalidOperationException("transient");
|
||||
await Task.Yield();
|
||||
});
|
||||
|
||||
attempts.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_DoesNotRetry_OnFailure()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var pipeline = builder.GetOrCreate("drv-test", "host-1", DriverCapability.Write, TierAOptions);
|
||||
var attempts = 0;
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
attempts++;
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("boom");
|
||||
});
|
||||
});
|
||||
|
||||
attempts.ShouldBe(1);
|
||||
ex.Message.ShouldBe("boom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmAcknowledge_DoesNotRetry_OnFailure()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var pipeline = builder.GetOrCreate("drv-test", "host-1", DriverCapability.AlarmAcknowledge, TierAOptions);
|
||||
var attempts = 0;
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
attempts++;
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("boom");
|
||||
});
|
||||
});
|
||||
|
||||
attempts.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pipeline_IsIsolated_PerHost()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var driverId = "drv-test";
|
||||
|
||||
var hostA = builder.GetOrCreate(driverId, "host-a", DriverCapability.Read, TierAOptions);
|
||||
var hostB = builder.GetOrCreate(driverId, "host-b", DriverCapability.Read, TierAOptions);
|
||||
|
||||
hostA.ShouldNotBeSameAs(hostB);
|
||||
builder.CachedPipelineCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pipeline_IsReused_ForSameTriple()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var driverId = "drv-test";
|
||||
|
||||
var first = builder.GetOrCreate(driverId, "host-a", DriverCapability.Read, TierAOptions);
|
||||
var second = builder.GetOrCreate(driverId, "host-a", DriverCapability.Read, TierAOptions);
|
||||
|
||||
first.ShouldBeSameAs(second);
|
||||
builder.CachedPipelineCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pipeline_IsIsolated_PerCapability()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var driverId = "drv-test";
|
||||
|
||||
var read = builder.GetOrCreate(driverId, "host-a", DriverCapability.Read, TierAOptions);
|
||||
var write = builder.GetOrCreate(driverId, "host-a", DriverCapability.Write, TierAOptions);
|
||||
|
||||
read.ShouldNotBeSameAs(write);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeadHost_DoesNotOpenBreaker_ForSiblingHost()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var driverId = "drv-test";
|
||||
|
||||
var deadHost = builder.GetOrCreate(driverId, "dead-plc", DriverCapability.Read, TierAOptions);
|
||||
var liveHost = builder.GetOrCreate(driverId, "live-plc", DriverCapability.Read, TierAOptions);
|
||||
|
||||
var threshold = TierAOptions.Resolve(DriverCapability.Read).BreakerFailureThreshold;
|
||||
for (var i = 0; i < threshold + 5; i++)
|
||||
{
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await deadHost.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("dead plc");
|
||||
}));
|
||||
}
|
||||
|
||||
var liveAttempts = 0;
|
||||
await liveHost.ExecuteAsync(async _ =>
|
||||
{
|
||||
liveAttempts++;
|
||||
await Task.Yield();
|
||||
});
|
||||
|
||||
liveAttempts.ShouldBe(1, "healthy sibling host must not be affected by dead peer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CircuitBreaker_Opens_AfterFailureThreshold_OnTierA()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var pipeline = builder.GetOrCreate("drv-test", "host-1", DriverCapability.Write, TierAOptions);
|
||||
|
||||
var threshold = TierAOptions.Resolve(DriverCapability.Write).BreakerFailureThreshold;
|
||||
for (var i = 0; i < threshold; i++)
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("boom");
|
||||
}));
|
||||
}
|
||||
|
||||
await Should.ThrowAsync<BrokenCircuitException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_Cancels_SlowOperation()
|
||||
{
|
||||
var tierAWithShortTimeout = new DriverResilienceOptions
|
||||
{
|
||||
Tier = DriverTier.A,
|
||||
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
||||
{
|
||||
[DriverCapability.Read] = new(TimeoutSeconds: 1, RetryCount: 0, BreakerFailureThreshold: 5),
|
||||
},
|
||||
};
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var pipeline = builder.GetOrCreate("drv-test", "host-1", DriverCapability.Read, tierAWithShortTimeout);
|
||||
|
||||
await Should.ThrowAsync<TimeoutRejectedException>(async () =>
|
||||
await pipeline.ExecuteAsync(async ct =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), ct);
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_Removes_OnlyMatchingInstance()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var keepId = "drv-keep";
|
||||
var dropId = "drv-drop";
|
||||
|
||||
builder.GetOrCreate(keepId, "h", DriverCapability.Read, TierAOptions);
|
||||
builder.GetOrCreate(keepId, "h", DriverCapability.Write, TierAOptions);
|
||||
builder.GetOrCreate(dropId, "h", DriverCapability.Read, TierAOptions);
|
||||
|
||||
var removed = builder.Invalidate(dropId);
|
||||
|
||||
removed.ShouldBe(1);
|
||||
builder.CachedPipelineCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_IsNot_Retried()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var pipeline = builder.GetOrCreate("drv-test", "host-1", DriverCapability.Read, TierAOptions);
|
||||
var attempts = 0;
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await pipeline.ExecuteAsync(async ct =>
|
||||
{
|
||||
attempts++;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await Task.Yield();
|
||||
}, cts.Token));
|
||||
|
||||
attempts.ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tracker_RecordsFailure_OnEveryRetry()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
|
||||
var pipeline = builder.GetOrCreate("drv-trk", "host-x", DriverCapability.Read, TierAOptions);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("always fails");
|
||||
}));
|
||||
|
||||
var snap = tracker.TryGet("drv-trk", "host-x");
|
||||
snap.ShouldNotBeNull();
|
||||
var retryCount = TierAOptions.Resolve(DriverCapability.Read).RetryCount;
|
||||
snap!.ConsecutiveFailures.ShouldBe(retryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tracker_StampsBreakerOpen_WhenBreakerTrips()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
|
||||
var pipeline = builder.GetOrCreate("drv-trk", "host-b", DriverCapability.Write, TierAOptions);
|
||||
|
||||
var threshold = TierAOptions.Resolve(DriverCapability.Write).BreakerFailureThreshold;
|
||||
for (var i = 0; i < threshold; i++)
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("boom");
|
||||
}));
|
||||
}
|
||||
|
||||
var snap = tracker.TryGet("drv-trk", "host-b");
|
||||
snap.ShouldNotBeNull();
|
||||
snap!.LastBreakerOpenUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tracker_IsolatesCounters_PerHost()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var builder = new DriverResiliencePipelineBuilder(statusTracker: tracker);
|
||||
var dead = builder.GetOrCreate("drv-trk", "dead", DriverCapability.Read, TierAOptions);
|
||||
var live = builder.GetOrCreate("drv-trk", "live", DriverCapability.Read, TierAOptions);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await dead.ExecuteAsync(async _ =>
|
||||
{
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("dead");
|
||||
}));
|
||||
await live.ExecuteAsync(async _ => await Task.Yield());
|
||||
|
||||
tracker.TryGet("drv-trk", "dead")!.ConsecutiveFailures.ShouldBeGreaterThan(0);
|
||||
tracker.TryGet("drv-trk", "live").ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverResilienceStatusTrackerTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void TryGet_Returns_Null_Before_AnyWrite()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
|
||||
tracker.TryGet("drv", "host").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailure_Accumulates_ConsecutiveFailures()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
|
||||
tracker.RecordFailure("drv", "host", Now);
|
||||
tracker.RecordFailure("drv", "host", Now.AddSeconds(1));
|
||||
tracker.RecordFailure("drv", "host", Now.AddSeconds(2));
|
||||
|
||||
tracker.TryGet("drv", "host")!.ConsecutiveFailures.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSuccess_Resets_ConsecutiveFailures()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
tracker.RecordFailure("drv", "host", Now);
|
||||
tracker.RecordFailure("drv", "host", Now.AddSeconds(1));
|
||||
|
||||
tracker.RecordSuccess("drv", "host", Now.AddSeconds(2));
|
||||
|
||||
tracker.TryGet("drv", "host")!.ConsecutiveFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordBreakerOpen_Populates_LastBreakerOpenUtc()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
|
||||
tracker.RecordBreakerOpen("drv", "host", Now);
|
||||
|
||||
tracker.TryGet("drv", "host")!.LastBreakerOpenUtc.ShouldBe(Now);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordRecycle_Populates_LastRecycleUtc()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
|
||||
tracker.RecordRecycle("drv", "host", Now);
|
||||
|
||||
tracker.TryGet("drv", "host")!.LastRecycleUtc.ShouldBe(Now);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFootprint_CapturesBaselineAndCurrent()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
|
||||
tracker.RecordFootprint("drv", "host", baselineBytes: 100_000_000, currentBytes: 150_000_000, Now);
|
||||
|
||||
var snap = tracker.TryGet("drv", "host")!;
|
||||
snap.BaselineFootprintBytes.ShouldBe(100_000_000);
|
||||
snap.CurrentFootprintBytes.ShouldBe(150_000_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentHosts_AreIndependent()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
|
||||
tracker.RecordFailure("drv", "host-a", Now);
|
||||
tracker.RecordFailure("drv", "host-b", Now);
|
||||
tracker.RecordSuccess("drv", "host-a", Now.AddSeconds(1));
|
||||
|
||||
tracker.TryGet("drv", "host-a")!.ConsecutiveFailures.ShouldBe(0);
|
||||
tracker.TryGet("drv", "host-b")!.ConsecutiveFailures.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ReturnsAll_TrackedPairs()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
tracker.RecordFailure("drv-1", "host-a", Now);
|
||||
tracker.RecordFailure("drv-1", "host-b", Now);
|
||||
tracker.RecordFailure("drv-2", "host-a", Now);
|
||||
|
||||
var snapshot = tracker.Snapshot();
|
||||
|
||||
snapshot.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentWrites_DoNotLose_Failures()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
Parallel.For(0, 500, _ => tracker.RecordFailure("drv", "host", Now));
|
||||
|
||||
tracker.TryGet("drv", "host")!.ConsecutiveFailures.ShouldBe(500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the Phase 6.1 Stream A.5 contract — wrapping a flaky
|
||||
/// <see cref="IReadable"/> / <see cref="IWritable"/> through the <see cref="CapabilityInvoker"/>.
|
||||
/// Exercises the three scenarios the plan enumerates: transient read succeeds after N
|
||||
/// retries; non-idempotent write fails after one attempt; idempotent write retries through.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class FlakeyDriverIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Read_SurfacesSuccess_AfterTransientFailures()
|
||||
{
|
||||
var flaky = new FlakeyDriver(failReadsBeforeIndex: 5);
|
||||
var options = new DriverResilienceOptions
|
||||
{
|
||||
Tier = DriverTier.A,
|
||||
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
||||
{
|
||||
// TimeoutSeconds=30 gives slack for 5 exponential-backoff retries under
|
||||
// parallel-test-execution CPU pressure; 10 retries at the default Delay=100ms
|
||||
// exponential can otherwise exceed a 2-second budget intermittently.
|
||||
[DriverCapability.Read] = new(TimeoutSeconds: 30, RetryCount: 10, BreakerFailureThreshold: 50),
|
||||
},
|
||||
};
|
||||
var invoker = new CapabilityInvoker(new DriverResiliencePipelineBuilder(), "drv-test", () => options);
|
||||
|
||||
var result = await invoker.ExecuteAsync(
|
||||
DriverCapability.Read,
|
||||
"host-1",
|
||||
async ct => await flaky.ReadAsync(["tag-a"], ct),
|
||||
CancellationToken.None);
|
||||
|
||||
flaky.ReadAttempts.ShouldBe(6);
|
||||
result[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_NonIdempotent_FailsOnFirstFailure_NoReplay()
|
||||
{
|
||||
var flaky = new FlakeyDriver(failWritesBeforeIndex: 3);
|
||||
var optionsWithAggressiveRetry = new DriverResilienceOptions
|
||||
{
|
||||
Tier = DriverTier.A,
|
||||
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
||||
{
|
||||
[DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 5, BreakerFailureThreshold: 50),
|
||||
},
|
||||
};
|
||||
var invoker = new CapabilityInvoker(new DriverResiliencePipelineBuilder(), "drv-test", () => optionsWithAggressiveRetry);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await invoker.ExecuteWriteAsync(
|
||||
"host-1",
|
||||
isIdempotent: false,
|
||||
async ct => await flaky.WriteAsync([new WriteRequest("pulse-coil", true)], ct),
|
||||
CancellationToken.None));
|
||||
|
||||
flaky.WriteAttempts.ShouldBe(1, "non-idempotent write must never replay (decision #44)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_Idempotent_RetriesUntilSuccess()
|
||||
{
|
||||
var flaky = new FlakeyDriver(failWritesBeforeIndex: 2);
|
||||
var optionsWithRetry = new DriverResilienceOptions
|
||||
{
|
||||
Tier = DriverTier.A,
|
||||
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
||||
{
|
||||
[DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 5, BreakerFailureThreshold: 50),
|
||||
},
|
||||
};
|
||||
var invoker = new CapabilityInvoker(new DriverResiliencePipelineBuilder(), "drv-test", () => optionsWithRetry);
|
||||
|
||||
var results = await invoker.ExecuteWriteAsync(
|
||||
"host-1",
|
||||
isIdempotent: true,
|
||||
async ct => await flaky.WriteAsync([new WriteRequest("set-point", 42.0f)], ct),
|
||||
CancellationToken.None);
|
||||
|
||||
flaky.WriteAttempts.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleHosts_OnOneDriver_HaveIndependentFailureCounts()
|
||||
{
|
||||
var flaky = new FlakeyDriver(failReadsBeforeIndex: 0);
|
||||
var options = new DriverResilienceOptions { Tier = DriverTier.A };
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var invoker = new CapabilityInvoker(builder, "drv-test", () => options);
|
||||
|
||||
// host-dead: force many failures to exhaust retries + trip breaker
|
||||
var threshold = options.Resolve(DriverCapability.Read).BreakerFailureThreshold;
|
||||
for (var i = 0; i < threshold + 5; i++)
|
||||
{
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await invoker.ExecuteAsync(DriverCapability.Read, "host-dead",
|
||||
_ => throw new InvalidOperationException("dead"),
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
// host-live: succeeds on first call — unaffected by the dead-host breaker
|
||||
var liveAttempts = 0;
|
||||
await invoker.ExecuteAsync(DriverCapability.Read, "host-live",
|
||||
_ => { liveAttempts++; return ValueTask.FromResult("ok"); },
|
||||
CancellationToken.None);
|
||||
|
||||
liveAttempts.ShouldBe(1);
|
||||
}
|
||||
|
||||
private sealed class FlakeyDriver : IReadable, IWritable
|
||||
{
|
||||
private readonly int _failReadsBeforeIndex;
|
||||
private readonly int _failWritesBeforeIndex;
|
||||
|
||||
public int ReadAttempts { get; private set; }
|
||||
public int WriteAttempts { get; private set; }
|
||||
|
||||
public FlakeyDriver(int failReadsBeforeIndex = 0, int failWritesBeforeIndex = 0)
|
||||
{
|
||||
_failReadsBeforeIndex = failReadsBeforeIndex;
|
||||
_failWritesBeforeIndex = failWritesBeforeIndex;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = ++ReadAttempts;
|
||||
if (attempt <= _failReadsBeforeIndex)
|
||||
throw new InvalidOperationException($"transient read failure #{attempt}");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> result = fullReferences
|
||||
.Select(_ => new DataValueSnapshot(Value: 0, StatusCode: 0u, SourceTimestampUtc: now, ServerTimestampUtc: now))
|
||||
.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = ++WriteAttempts;
|
||||
if (attempt <= _failWritesBeforeIndex)
|
||||
throw new InvalidOperationException($"transient write failure #{attempt}");
|
||||
|
||||
IReadOnlyList<WriteResult> result = writes.Select(_ => new WriteResult(0u)).ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InFlightCounterTests
|
||||
{
|
||||
[Fact]
|
||||
public void StartThenComplete_NetsToZero()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
tracker.RecordCallStart("drv", "host-a");
|
||||
tracker.RecordCallComplete("drv", "host-a");
|
||||
|
||||
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedStarts_SumDepth()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
tracker.RecordCallStart("drv", "host-a");
|
||||
tracker.RecordCallStart("drv", "host-a");
|
||||
tracker.RecordCallStart("drv", "host-a");
|
||||
|
||||
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(3);
|
||||
|
||||
tracker.RecordCallComplete("drv", "host-a");
|
||||
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteBeforeStart_ClampedToZero()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
tracker.RecordCallComplete("drv", "host-a");
|
||||
|
||||
// A stray Complete without a matching Start shouldn't drive the counter negative.
|
||||
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentHosts_TrackIndependently()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
tracker.RecordCallStart("drv", "host-a");
|
||||
tracker.RecordCallStart("drv", "host-a");
|
||||
tracker.RecordCallStart("drv", "host-b");
|
||||
|
||||
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(2);
|
||||
tracker.TryGet("drv", "host-b")!.CurrentInFlight.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentStarts_DoNotLose_Count()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
Parallel.For(0, 500, _ => tracker.RecordCallStart("drv", "host-a"));
|
||||
|
||||
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapabilityInvoker_IncrementsTracker_DuringExecution()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var invoker = new CapabilityInvoker(
|
||||
new DriverResiliencePipelineBuilder(),
|
||||
"drv-live",
|
||||
() => new DriverResilienceOptions { Tier = DriverTier.A },
|
||||
driverType: "Modbus",
|
||||
statusTracker: tracker);
|
||||
|
||||
var observedMidCall = -1;
|
||||
await invoker.ExecuteAsync(
|
||||
DriverCapability.Read,
|
||||
"plc-1",
|
||||
async _ =>
|
||||
{
|
||||
observedMidCall = tracker.TryGet("drv-live", "plc-1")?.CurrentInFlight ?? -1;
|
||||
await Task.Yield();
|
||||
return 42;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
observedMidCall.ShouldBe(1, "during call, in-flight == 1");
|
||||
tracker.TryGet("drv-live", "plc-1")!.CurrentInFlight.ShouldBe(0, "post-call, counter decremented");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapabilityInvoker_ExceptionPath_DecrementsCounter()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var invoker = new CapabilityInvoker(
|
||||
new DriverResiliencePipelineBuilder(),
|
||||
"drv-live",
|
||||
() => new DriverResilienceOptions { Tier = DriverTier.A },
|
||||
statusTracker: tracker);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await invoker.ExecuteAsync<int>(
|
||||
DriverCapability.Write,
|
||||
"plc-1",
|
||||
_ => throw new InvalidOperationException("boom"),
|
||||
CancellationToken.None));
|
||||
|
||||
tracker.TryGet("drv-live", "plc-1")!.CurrentInFlight.ShouldBe(0,
|
||||
"finally-block must decrement even when call-site throws");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapabilityInvoker_WithoutTracker_DoesNotThrow()
|
||||
{
|
||||
var invoker = new CapabilityInvoker(
|
||||
new DriverResiliencePipelineBuilder(),
|
||||
"drv-live",
|
||||
() => new DriverResilienceOptions { Tier = DriverTier.A },
|
||||
statusTracker: null);
|
||||
|
||||
var result = await invoker.ExecuteAsync(
|
||||
DriverCapability.Read, "host-1",
|
||||
_ => ValueTask.FromResult(7),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(7);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the per-call host resolver contract against the shared
|
||||
/// <see cref="DriverResiliencePipelineBuilder"/> + <see cref="CapabilityInvoker"/> — one
|
||||
/// dead PLC behind a multi-device driver must NOT open the breaker for healthy sibling
|
||||
/// PLCs (decision #144).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PerCallHostResolverDispatchTests
|
||||
{
|
||||
private sealed class StaticResolver : IPerCallHostResolver
|
||||
{
|
||||
private readonly Dictionary<string, string> _map;
|
||||
public StaticResolver(Dictionary<string, string> map) => _map = map;
|
||||
public string ResolveHost(string fullReference) =>
|
||||
_map.TryGetValue(fullReference, out var host) ? host : string.Empty;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeadPlc_DoesNotOpenBreaker_For_HealthyPlc_With_Resolver()
|
||||
{
|
||||
// Two PLCs behind one driver. Dead PLC keeps failing; healthy PLC must keep serving.
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var options = new DriverResilienceOptions { Tier = DriverTier.B };
|
||||
var invoker = new CapabilityInvoker(builder, "drv-modbus", () => options);
|
||||
|
||||
var resolver = new StaticResolver(new Dictionary<string, string>
|
||||
{
|
||||
["tag-on-dead"] = "plc-dead",
|
||||
["tag-on-alive"] = "plc-alive",
|
||||
});
|
||||
|
||||
var threshold = options.Resolve(DriverCapability.Read).BreakerFailureThreshold;
|
||||
for (var i = 0; i < threshold + 3; i++)
|
||||
{
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await invoker.ExecuteAsync(
|
||||
DriverCapability.Read,
|
||||
hostName: resolver.ResolveHost("tag-on-dead"),
|
||||
_ => throw new InvalidOperationException("plc-dead unreachable"),
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
// Healthy PLC's pipeline is in a different bucket; the first call should succeed
|
||||
// without hitting the dead-PLC breaker.
|
||||
var aliveAttempts = 0;
|
||||
await invoker.ExecuteAsync(
|
||||
DriverCapability.Read,
|
||||
hostName: resolver.ResolveHost("tag-on-alive"),
|
||||
_ => { aliveAttempts++; return ValueTask.FromResult("ok"); },
|
||||
CancellationToken.None);
|
||||
|
||||
aliveAttempts.ShouldBe(1, "decision #144 — per-PLC isolation keeps healthy PLCs serving");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolver_EmptyString_Treated_As_Single_Host_Fallback()
|
||||
{
|
||||
var resolver = new StaticResolver(new Dictionary<string, string>
|
||||
{
|
||||
["tag-unknown"] = "",
|
||||
});
|
||||
|
||||
resolver.ResolveHost("tag-unknown").ShouldBe("");
|
||||
resolver.ResolveHost("not-in-map").ShouldBe("", "unknown refs return empty so dispatch falls back to single-host");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithoutResolver_SameHost_Shares_One_Pipeline()
|
||||
{
|
||||
// Without a resolver all calls share the DriverInstanceId pipeline — that's the
|
||||
// pre-decision-#144 behavior single-host drivers should keep.
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var options = new DriverResilienceOptions { Tier = DriverTier.A };
|
||||
var invoker = new CapabilityInvoker(builder, "drv-single", () => options);
|
||||
|
||||
await invoker.ExecuteAsync(DriverCapability.Read, "drv-single",
|
||||
_ => ValueTask.FromResult("a"), CancellationToken.None);
|
||||
await invoker.ExecuteAsync(DriverCapability.Read, "drv-single",
|
||||
_ => ValueTask.FromResult("b"), CancellationToken.None);
|
||||
|
||||
builder.CachedPipelineCount.ShouldBe(1, "single-host drivers share one pipeline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithResolver_TwoHosts_Get_Two_Pipelines()
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var options = new DriverResilienceOptions { Tier = DriverTier.B };
|
||||
var invoker = new CapabilityInvoker(builder, "drv-modbus", () => options);
|
||||
var resolver = new StaticResolver(new Dictionary<string, string>
|
||||
{
|
||||
["tag-a"] = "plc-a",
|
||||
["tag-b"] = "plc-b",
|
||||
});
|
||||
|
||||
await invoker.ExecuteAsync(DriverCapability.Read, resolver.ResolveHost("tag-a"),
|
||||
_ => ValueTask.FromResult(1), CancellationToken.None);
|
||||
await invoker.ExecuteAsync(DriverCapability.Read, resolver.ResolveHost("tag-b"),
|
||||
_ => ValueTask.FromResult(2), CancellationToken.None);
|
||||
|
||||
builder.CachedPipelineCount.ShouldBe(2, "each host keyed on its own pipeline");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MemoryRecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TierC_HardBreach_RequestsSupervisorRecycle()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var recycle = new MemoryRecycle(DriverTier.C, supervisor, NullLogger<MemoryRecycle>.Instance);
|
||||
|
||||
var requested = await recycle.HandleAsync(MemoryTrackingAction.HardBreach, 2_000_000_000, CancellationToken.None);
|
||||
|
||||
requested.ShouldBeTrue();
|
||||
supervisor.RecycleCount.ShouldBe(1);
|
||||
supervisor.LastReason.ShouldContain("hard-breach");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
public async Task InProcessTier_HardBreach_NeverRequestsRecycle(DriverTier tier)
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var recycle = new MemoryRecycle(tier, supervisor, NullLogger<MemoryRecycle>.Instance);
|
||||
|
||||
var requested = await recycle.HandleAsync(MemoryTrackingAction.HardBreach, 2_000_000_000, CancellationToken.None);
|
||||
|
||||
requested.ShouldBeFalse("Tier A/B hard-breach logs a promotion recommendation only (decisions #74, #145)");
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TierC_WithoutSupervisor_HardBreach_NoOp()
|
||||
{
|
||||
var recycle = new MemoryRecycle(DriverTier.C, supervisor: null, NullLogger<MemoryRecycle>.Instance);
|
||||
|
||||
var requested = await recycle.HandleAsync(MemoryTrackingAction.HardBreach, 2_000_000_000, CancellationToken.None);
|
||||
|
||||
requested.ShouldBeFalse("no supervisor → no recycle path; action logged only");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
[InlineData(DriverTier.C)]
|
||||
public async Task SoftBreach_NeverRequestsRecycle(DriverTier tier)
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var recycle = new MemoryRecycle(tier, supervisor, NullLogger<MemoryRecycle>.Instance);
|
||||
|
||||
var requested = await recycle.HandleAsync(MemoryTrackingAction.SoftBreach, 1_000_000_000, CancellationToken.None);
|
||||
|
||||
requested.ShouldBeFalse("soft-breach is surface-only at every tier");
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MemoryTrackingAction.None)]
|
||||
[InlineData(MemoryTrackingAction.Warming)]
|
||||
public async Task NonBreachActions_NoOp(MemoryTrackingAction action)
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var recycle = new MemoryRecycle(DriverTier.C, supervisor, NullLogger<MemoryRecycle>.Instance);
|
||||
|
||||
var requested = await recycle.HandleAsync(action, 100_000_000, CancellationToken.None);
|
||||
|
||||
requested.ShouldBeFalse();
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
private sealed class FakeSupervisor : IDriverSupervisor
|
||||
{
|
||||
public string DriverInstanceId => "fake-tier-c";
|
||||
public int RecycleCount { get; private set; }
|
||||
public string? LastReason { get; private set; }
|
||||
|
||||
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
RecycleCount++;
|
||||
LastReason = reason;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MemoryTrackingTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void WarmingUp_Returns_Warming_UntilWindowElapses()
|
||||
{
|
||||
var tracker = new MemoryTracking(DriverTier.A, TimeSpan.FromMinutes(5));
|
||||
|
||||
tracker.Sample(100_000_000, T0).ShouldBe(MemoryTrackingAction.Warming);
|
||||
tracker.Sample(105_000_000, T0.AddMinutes(1)).ShouldBe(MemoryTrackingAction.Warming);
|
||||
tracker.Sample(102_000_000, T0.AddMinutes(4.9)).ShouldBe(MemoryTrackingAction.Warming);
|
||||
|
||||
tracker.Phase.ShouldBe(TrackingPhase.WarmingUp);
|
||||
tracker.BaselineBytes.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowElapsed_CapturesBaselineAsMedian_AndTransitionsToSteady()
|
||||
{
|
||||
var tracker = new MemoryTracking(DriverTier.A, TimeSpan.FromMinutes(5));
|
||||
|
||||
tracker.Sample(100_000_000, T0);
|
||||
tracker.Sample(200_000_000, T0.AddMinutes(1));
|
||||
tracker.Sample(150_000_000, T0.AddMinutes(2));
|
||||
var first = tracker.Sample(150_000_000, T0.AddMinutes(5));
|
||||
|
||||
tracker.Phase.ShouldBe(TrackingPhase.Steady);
|
||||
tracker.BaselineBytes.ShouldBe(150_000_000L, "median of 4 samples [100, 200, 150, 150] = (150+150)/2 = 150");
|
||||
first.ShouldBe(MemoryTrackingAction.None, "150 MB is the baseline itself, well under soft threshold");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A, 3, 50)]
|
||||
[InlineData(DriverTier.B, 3, 100)]
|
||||
[InlineData(DriverTier.C, 2, 500)]
|
||||
public void GetTierConstants_MatchesDecision146(DriverTier tier, int expectedMultiplier, long expectedFloorMB)
|
||||
{
|
||||
var (multiplier, floor) = MemoryTracking.GetTierConstants(tier);
|
||||
multiplier.ShouldBe(expectedMultiplier);
|
||||
floor.ShouldBe(expectedFloorMB * 1024 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SoftThreshold_UsesMax_OfMultiplierAndFloor_SmallBaseline()
|
||||
{
|
||||
// Tier A: mult=3, floor=50 MB. Baseline 10 MB → 3×10=30 MB < 10+50=60 MB → floor wins.
|
||||
var tracker = WarmupWithBaseline(DriverTier.A, 10L * 1024 * 1024);
|
||||
tracker.SoftThresholdBytes.ShouldBe(60L * 1024 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SoftThreshold_UsesMax_OfMultiplierAndFloor_LargeBaseline()
|
||||
{
|
||||
// Tier A: mult=3, floor=50 MB. Baseline 200 MB → 3×200=600 MB > 200+50=250 MB → multiplier wins.
|
||||
var tracker = WarmupWithBaseline(DriverTier.A, 200L * 1024 * 1024);
|
||||
tracker.SoftThresholdBytes.ShouldBe(600L * 1024 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardThreshold_IsTwiceSoft()
|
||||
{
|
||||
var tracker = WarmupWithBaseline(DriverTier.B, 200L * 1024 * 1024);
|
||||
tracker.HardThresholdBytes.ShouldBe(tracker.SoftThresholdBytes * 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sample_Below_Soft_Returns_None()
|
||||
{
|
||||
var tracker = WarmupWithBaseline(DriverTier.A, 100L * 1024 * 1024);
|
||||
|
||||
tracker.Sample(200L * 1024 * 1024, T0.AddMinutes(10)).ShouldBe(MemoryTrackingAction.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sample_AtSoft_Returns_SoftBreach()
|
||||
{
|
||||
// Tier A, baseline 200 MB → soft = 600 MB. Sample exactly at soft.
|
||||
var tracker = WarmupWithBaseline(DriverTier.A, 200L * 1024 * 1024);
|
||||
|
||||
tracker.Sample(tracker.SoftThresholdBytes, T0.AddMinutes(10))
|
||||
.ShouldBe(MemoryTrackingAction.SoftBreach);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sample_AtHard_Returns_HardBreach()
|
||||
{
|
||||
var tracker = WarmupWithBaseline(DriverTier.A, 200L * 1024 * 1024);
|
||||
|
||||
tracker.Sample(tracker.HardThresholdBytes, T0.AddMinutes(10))
|
||||
.ShouldBe(MemoryTrackingAction.HardBreach);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sample_AboveHard_Returns_HardBreach()
|
||||
{
|
||||
var tracker = WarmupWithBaseline(DriverTier.A, 200L * 1024 * 1024);
|
||||
|
||||
tracker.Sample(tracker.HardThresholdBytes + 100_000_000, T0.AddMinutes(10))
|
||||
.ShouldBe(MemoryTrackingAction.HardBreach);
|
||||
}
|
||||
|
||||
private static MemoryTracking WarmupWithBaseline(DriverTier tier, long baseline)
|
||||
{
|
||||
var tracker = new MemoryTracking(tier, TimeSpan.FromMinutes(5));
|
||||
tracker.Sample(baseline, T0);
|
||||
tracker.Sample(baseline, T0.AddMinutes(5));
|
||||
tracker.BaselineBytes.ShouldBe(baseline);
|
||||
return tracker;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
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);
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
public void TierAOrB_Ctor_Throws(DriverTier tier)
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
Should.Throw<ArgumentException>(() => new ScheduledRecycleScheduler(
|
||||
tier, Weekly, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroOrNegativeInterval_Throws()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
Should.Throw<ArgumentException>(() => new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.Zero, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
Should.Throw<ArgumentException>(() => new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromSeconds(-1), T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_BeforeNextRecycle_NoOp()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
var fired = await sch.TickAsync(T0 + TimeSpan.FromDays(6), CancellationToken.None);
|
||||
|
||||
fired.ShouldBeFalse();
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_AtOrAfterNextRecycle_FiresOnce_AndAdvances()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
var fired = await sch.TickAsync(T0 + Weekly + TimeSpan.FromMinutes(1), CancellationToken.None);
|
||||
|
||||
fired.ShouldBeTrue();
|
||||
supervisor.RecycleCount.ShouldBe(1);
|
||||
sch.NextRecycleUtc.ShouldBe(T0 + Weekly + Weekly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRecycleNow_Fires_Immediately_WithoutAdvancingSchedule()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var sch = new ScheduledRecycleScheduler(DriverTier.C, Weekly, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
var nextBefore = sch.NextRecycleUtc;
|
||||
|
||||
await sch.RequestRecycleNowAsync("memory hard-breach", CancellationToken.None);
|
||||
|
||||
supervisor.RecycleCount.ShouldBe(1);
|
||||
supervisor.LastReason.ShouldBe("memory hard-breach");
|
||||
sch.NextRecycleUtc.ShouldBe(nextBefore, "ad-hoc recycle doesn't shift the cron schedule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleFires_AcrossTicks_AdvanceOneIntervalEach()
|
||||
{
|
||||
var supervisor = new FakeSupervisor();
|
||||
var sch = new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromDays(1), T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
await sch.TickAsync(T0 + TimeSpan.FromDays(1) + TimeSpan.FromHours(1), CancellationToken.None);
|
||||
await sch.TickAsync(T0 + TimeSpan.FromDays(2) + TimeSpan.FromHours(1), CancellationToken.None);
|
||||
await sch.TickAsync(T0 + TimeSpan.FromDays(3) + TimeSpan.FromHours(1), CancellationToken.None);
|
||||
|
||||
supervisor.RecycleCount.ShouldBe(3);
|
||||
sch.NextRecycleUtc.ShouldBe(T0 + TimeSpan.FromDays(4));
|
||||
}
|
||||
|
||||
private sealed class FakeSupervisor : IDriverSupervisor
|
||||
{
|
||||
public string DriverInstanceId => "tier-c-fake";
|
||||
public int RecycleCount { get; private set; }
|
||||
public string? LastReason { get; private set; }
|
||||
|
||||
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
RecycleCount++;
|
||||
LastReason = reason;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
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);
|
||||
|
||||
[Fact]
|
||||
public void SubSixtySecondThreshold_ClampsToSixty()
|
||||
{
|
||||
var detector = new WedgeDetector(TimeSpan.FromSeconds(10));
|
||||
detector.Threshold.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unhealthy_Driver_AlwaysNotApplicable()
|
||||
{
|
||||
var detector = new WedgeDetector(Threshold);
|
||||
var demand = new DemandSignal(BulkheadDepth: 5, ActiveMonitoredItems: 10, QueuedHistoryReads: 0, LastProgressUtc: Now.AddMinutes(-10));
|
||||
|
||||
detector.Classify(DriverState.Faulted, demand, Now).ShouldBe(WedgeVerdict.NotApplicable);
|
||||
detector.Classify(DriverState.Degraded, demand, Now).ShouldBe(WedgeVerdict.NotApplicable);
|
||||
detector.Classify(DriverState.Initializing, demand, Now).ShouldBe(WedgeVerdict.NotApplicable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Idle_Subscription_Only_StaysIdle()
|
||||
{
|
||||
// Idle driver: bulkhead 0, monitored items 0, no history reads queued.
|
||||
// Even if LastProgressUtc is ancient, the verdict is Idle, not Faulted.
|
||||
var detector = new WedgeDetector(Threshold);
|
||||
var demand = new DemandSignal(0, 0, 0, Now.AddHours(-12));
|
||||
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Idle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingWork_WithRecentProgress_StaysHealthy()
|
||||
{
|
||||
var detector = new WedgeDetector(Threshold);
|
||||
var demand = new DemandSignal(BulkheadDepth: 2, ActiveMonitoredItems: 0, QueuedHistoryReads: 0, LastProgressUtc: Now.AddSeconds(-30));
|
||||
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingWork_WithStaleProgress_IsFaulted()
|
||||
{
|
||||
var detector = new WedgeDetector(Threshold);
|
||||
var demand = new DemandSignal(BulkheadDepth: 2, ActiveMonitoredItems: 0, QueuedHistoryReads: 0, LastProgressUtc: Now.AddMinutes(-5));
|
||||
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MonitoredItems_Active_ButNoRecentPublish_IsFaulted()
|
||||
{
|
||||
// Subscription-only driver with live MonitoredItems but no publish progress within threshold
|
||||
// is a real wedge — this is the case the previous "no successful Read" formulation used
|
||||
// to miss (no reads ever happen).
|
||||
var detector = new WedgeDetector(Threshold);
|
||||
var demand = new DemandSignal(BulkheadDepth: 0, ActiveMonitoredItems: 5, QueuedHistoryReads: 0, LastProgressUtc: Now.AddMinutes(-10));
|
||||
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MonitoredItems_Active_WithFreshPublish_StaysHealthy()
|
||||
{
|
||||
var detector = new WedgeDetector(Threshold);
|
||||
var demand = new DemandSignal(BulkheadDepth: 0, ActiveMonitoredItems: 5, QueuedHistoryReads: 0, LastProgressUtc: Now.AddSeconds(-10));
|
||||
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryBackfill_SlowButMakingProgress_StaysHealthy()
|
||||
{
|
||||
// Slow historian backfill — QueuedHistoryReads > 0 but progress advances within threshold.
|
||||
var detector = new WedgeDetector(Threshold);
|
||||
var demand = new DemandSignal(BulkheadDepth: 0, ActiveMonitoredItems: 0, QueuedHistoryReads: 50, LastProgressUtc: Now.AddSeconds(-60));
|
||||
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteOnlyBurst_StaysIdle_WhenBulkheadEmpty()
|
||||
{
|
||||
// A write-only driver that just finished a burst: bulkhead drained, no subscriptions, no
|
||||
// history reads. Idle — the previous formulation would have faulted here because no
|
||||
// reads were succeeding even though the driver is perfectly healthy.
|
||||
var detector = new WedgeDetector(Threshold);
|
||||
var demand = new DemandSignal(0, 0, 0, Now.AddMinutes(-30));
|
||||
|
||||
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Idle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DemandSignal_HasPendingWork_TrueForAnyNonZeroCounter()
|
||||
{
|
||||
new DemandSignal(1, 0, 0, Now).HasPendingWork.ShouldBeTrue();
|
||||
new DemandSignal(0, 1, 0, Now).HasPendingWork.ShouldBeTrue();
|
||||
new DemandSignal(0, 0, 1, Now).HasPendingWork.ShouldBeTrue();
|
||||
new DemandSignal(0, 0, 0, Now).HasPendingWork.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,166 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies cycle detection + topological sort on the virtual-tag dependency
|
||||
/// graph. Publish-time correctness depends on these being right — a missed cycle
|
||||
/// would deadlock cascade evaluation; a wrong topological order would miscompute
|
||||
/// chained virtual tags.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DependencyGraphTests
|
||||
{
|
||||
private static IReadOnlySet<string> Set(params string[] items) =>
|
||||
new HashSet<string>(items, StringComparer.Ordinal);
|
||||
|
||||
[Fact]
|
||||
public void Empty_graph_produces_empty_sort_and_no_cycles()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.TopologicalSort().ShouldBeEmpty();
|
||||
g.DetectCycles().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_node_with_no_deps()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set());
|
||||
g.TopologicalSort().ShouldBe(new[] { "A" });
|
||||
g.DetectCycles().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Topological_order_places_dependencies_before_dependents()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("B", Set("A")); // B depends on A
|
||||
g.Add("C", Set("B", "A")); // C depends on B + A
|
||||
g.Add("A", Set()); // A is a leaf
|
||||
|
||||
var order = g.TopologicalSort();
|
||||
var idx = order.Select((x, i) => (x, i)).ToDictionary(p => p.x, p => p.i);
|
||||
idx["A"].ShouldBeLessThan(idx["B"]);
|
||||
idx["B"].ShouldBeLessThan(idx["C"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Self_loop_detected_as_cycle()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set("A"));
|
||||
var cycles = g.DetectCycles();
|
||||
cycles.Count.ShouldBe(1);
|
||||
cycles[0].ShouldContain("A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_node_cycle_detected()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set("B"));
|
||||
g.Add("B", Set("A"));
|
||||
var cycles = g.DetectCycles();
|
||||
cycles.Count.ShouldBe(1);
|
||||
cycles[0].Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Three_node_cycle_detected()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set("B"));
|
||||
g.Add("B", Set("C"));
|
||||
g.Add("C", Set("A"));
|
||||
var cycles = g.DetectCycles();
|
||||
cycles.Count.ShouldBe(1);
|
||||
cycles[0].Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_disjoint_cycles_all_reported()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
// Cycle 1: A -> B -> A
|
||||
g.Add("A", Set("B"));
|
||||
g.Add("B", Set("A"));
|
||||
// Cycle 2: X -> Y -> Z -> X
|
||||
g.Add("X", Set("Y"));
|
||||
g.Add("Y", Set("Z"));
|
||||
g.Add("Z", Set("X"));
|
||||
// Clean leaf: M
|
||||
g.Add("M", Set());
|
||||
|
||||
var cycles = g.DetectCycles();
|
||||
cycles.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Topological_sort_throws_DependencyCycleException_on_cycle()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set("B"));
|
||||
g.Add("B", Set("A"));
|
||||
Should.Throw<DependencyCycleException>(() => g.TopologicalSort())
|
||||
.Cycles.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectDependents_returns_direct_only()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("B", Set("A"));
|
||||
g.Add("C", Set("B"));
|
||||
g.DirectDependents("A").ShouldBe(new[] { "B" });
|
||||
g.DirectDependents("B").ShouldBe(new[] { "C" });
|
||||
g.DirectDependents("C").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransitiveDependentsInOrder_returns_topological_closure()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("B", Set("A"));
|
||||
g.Add("C", Set("B"));
|
||||
g.Add("D", Set("C"));
|
||||
var closure = g.TransitiveDependentsInOrder("A");
|
||||
closure.ShouldBe(new[] { "B", "C", "D" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Readding_a_node_overwrites_prior_dependencies()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("X", Set("A"));
|
||||
g.DirectDependencies("X").ShouldBe(new[] { "A" });
|
||||
// Re-add with different deps (simulates script edit + republish).
|
||||
g.Add("X", Set("B", "C"));
|
||||
g.DirectDependencies("X").OrderBy(s => s).ShouldBe(new[] { "B", "C" });
|
||||
// A should no longer list X as a dependent.
|
||||
g.DirectDependents("A").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leaf_dependencies_not_registered_as_nodes_are_treated_as_implicit()
|
||||
{
|
||||
// A is referenced but never Add'd as a node — it's an upstream driver tag.
|
||||
var g = new DependencyGraph();
|
||||
g.Add("B", Set("A"));
|
||||
g.TopologicalSort().ShouldBe(new[] { "B" });
|
||||
g.DirectDependents("A").ShouldBe(new[] { "B" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deep_graph_no_stack_overflow()
|
||||
{
|
||||
// Iterative Tarjan's + Kahn's — 10k deep chain must complete without blowing the stack.
|
||||
var g = new DependencyGraph();
|
||||
for (var i = 1; i < 10_000; i++)
|
||||
g.Add($"N{i}", Set($"N{i - 1}"));
|
||||
var order = g.TopologicalSort();
|
||||
order.Count.ShouldBe(9_999);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="ITagUpstreamSource"/> for tests. Seed tag values via
|
||||
/// <see cref="Set"/>, push changes via <see cref="Push"/>. Tracks subscriptions so
|
||||
/// tests can assert the engine disposes them on reload / shutdown.
|
||||
/// </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);
|
||||
|
||||
public int ActiveSubscriptionCount { get; private set; }
|
||||
|
||||
public void Set(string path, object value, uint statusCode = 0u)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
|
||||
}
|
||||
|
||||
public void Push(string path, object value, uint statusCode = 0u)
|
||||
{
|
||||
Set(path, value, statusCode);
|
||||
if (_subs.TryGetValue(path, out var list))
|
||||
{
|
||||
Action<string, DataValueSnapshot>[] snap;
|
||||
lock (list) { snap = list.ToArray(); }
|
||||
foreach (var obs in snap) obs(path, _values[path]);
|
||||
}
|
||||
}
|
||||
|
||||
public DataValueSnapshot ReadTag(string path)
|
||||
=> _values.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
|
||||
|
||||
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
var list = _subs.GetOrAdd(path, _ => []);
|
||||
lock (list) { list.Add(observer); }
|
||||
ActiveSubscriptionCount++;
|
||||
return new Unsub(this, path, observer);
|
||||
}
|
||||
|
||||
private sealed class Unsub : IDisposable
|
||||
{
|
||||
private readonly FakeUpstream _up;
|
||||
private readonly string _path;
|
||||
private readonly Action<string, DataValueSnapshot> _observer;
|
||||
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
_up = up; _path = path; _observer = observer;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
if (_up._subs.TryGetValue(_path, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
if (list.Remove(_observer))
|
||||
_up.ActiveSubscriptionCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TimerTriggerSchedulerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Timer_interval_causes_periodic_reevaluation()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
// Counter source — re-eval should pick up new value each tick.
|
||||
var counter = 0;
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger),
|
||||
logger);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Counter", DriverDataType.Int32,
|
||||
"""return ctx.Now.Millisecond;""", // changes on every evaluation
|
||||
ChangeTriggered: false,
|
||||
TimerInterval: TimeSpan.FromMilliseconds(100))]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"Counter", DriverDataType.Int32,
|
||||
"""return ctx.Now.Millisecond;""",
|
||||
ChangeTriggered: false,
|
||||
TimerInterval: TimeSpan.FromMilliseconds(100))]);
|
||||
|
||||
// Watch the value change across ticks.
|
||||
var snapshots = new List<object?>();
|
||||
using var sub = engine.Subscribe("Counter", (_, v) => snapshots.Add(v.Value));
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
snapshots.Count.ShouldBeGreaterThanOrEqualTo(3, "At least 3 ticks in 500ms at 100ms cadence");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tags_without_TimerInterval_not_scheduled()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"NoTimer", DriverDataType.Int32, """return 1;""")]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"NoTimer", DriverDataType.Int32, """return 1;""")]);
|
||||
|
||||
var events = new List<int>();
|
||||
using var sub = engine.Subscribe("NoTimer", (_, v) => events.Add((int)(v.Value ?? 0)));
|
||||
|
||||
await Task.Delay(300);
|
||||
events.Count.ShouldBe(0, "No TimerInterval = no timer ticks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_groups_tags_by_interval_into_shared_timers()
|
||||
{
|
||||
// Smoke test — Start on a definition list with two distinct intervals must not
|
||||
// throw. Group count matches unique intervals.
|
||||
var up = new FakeUpstream();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Fast", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
new VirtualTagDefinition("Slow", DriverDataType.Int32, """return 2;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(5)),
|
||||
new VirtualTagDefinition("AlsoFast", DriverDataType.Int32, """return 3;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
Should.NotThrow(() => sched.Start(new[]
|
||||
{
|
||||
new VirtualTagDefinition("Fast", DriverDataType.Int32, """return 1;""", TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
new VirtualTagDefinition("Slow", DriverDataType.Int32, """return 2;""", TimerInterval: TimeSpan.FromSeconds(5)),
|
||||
new VirtualTagDefinition("AlsoFast", DriverDataType.Int32, """return 3;""", TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposed_scheduler_stops_firing()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromMilliseconds(50))]);
|
||||
|
||||
var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromMilliseconds(50))]);
|
||||
sched.Dispose();
|
||||
|
||||
// After dispose, second Start throws ObjectDisposedException.
|
||||
Should.Throw<ObjectDisposedException>(() =>
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromMilliseconds(50))]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end VirtualTagEngine behavior: load config, subscribe to upstream,
|
||||
/// evaluate on change, cascade through dependent virtual tags, timer-driven
|
||||
/// re-evaluation, error isolation, historize flag, cycle rejection.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VirtualTagEngineTests
|
||||
{
|
||||
private static VirtualTagEngine Build(
|
||||
FakeUpstream upstream,
|
||||
IHistoryWriter? history = null,
|
||||
TimeSpan? scriptTimeout = null,
|
||||
Func<DateTime>? clock = null)
|
||||
{
|
||||
var rootLogger = new LoggerConfiguration().CreateLogger();
|
||||
return new VirtualTagEngine(
|
||||
upstream,
|
||||
new ScriptLoggerFactory(rootLogger),
|
||||
rootLogger,
|
||||
history,
|
||||
clock,
|
||||
scriptTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Simple_script_reads_upstream_and_returns_coerced_value()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("InTag", 10.0);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
Path: "LineRate",
|
||||
DataType: DriverDataType.Float64,
|
||||
ScriptSource: """return (double)ctx.GetTag("InTag").Value * 2.0;""")]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var result = engine.Read("LineRate");
|
||||
result.StatusCode.ShouldBe(0u);
|
||||
result.Value.ShouldBe(20.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upstream_change_triggers_cascade_through_two_levels()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("A", 1.0);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("B", DriverDataType.Float64,
|
||||
"""return (double)ctx.GetTag("A").Value + 10.0;"""),
|
||||
new VirtualTagDefinition("C", DriverDataType.Float64,
|
||||
"""return (double)ctx.GetTag("B").Value * 2.0;"""),
|
||||
]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("B").Value.ShouldBe(11.0);
|
||||
engine.Read("C").Value.ShouldBe(22.0);
|
||||
|
||||
// Change upstream — cascade should recompute B (11→15.0) then C (30.0)
|
||||
up.Push("A", 5.0);
|
||||
await WaitForConditionAsync(() => Equals(engine.Read("B").Value, 15.0));
|
||||
engine.Read("B").Value.ShouldBe(15.0);
|
||||
engine.Read("C").Value.ShouldBe(30.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cycle_in_virtual_tags_rejected_at_Load()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
Should.Throw<DependencyCycleException>(() => engine.Load([
|
||||
new VirtualTagDefinition("A", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value + 1;"""),
|
||||
new VirtualTagDefinition("B", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value + 1;"""),
|
||||
]));
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_compile_error_surfaces_at_Load_with_all_failures()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||
new VirtualTagDefinition("A", DriverDataType.Int32, """return undefinedIdentifier;"""),
|
||||
new VirtualTagDefinition("B", DriverDataType.Int32, """return 42;"""),
|
||||
new VirtualTagDefinition("C", DriverDataType.Int32, """var x = anotherUndefined; return x;"""),
|
||||
]));
|
||||
ex.Message.ShouldContain("2 script(s) did not compile");
|
||||
ex.Message.ShouldContain("A");
|
||||
ex.Message.ShouldContain("C");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_runtime_exception_isolates_to_owning_tag()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("OK", 10);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("GoodTag", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("OK").Value * 2;"""),
|
||||
new VirtualTagDefinition("BadTag", DriverDataType.Int32,
|
||||
"""throw new InvalidOperationException("boom");"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("GoodTag").StatusCode.ShouldBe(0u);
|
||||
engine.Read("GoodTag").Value.ShouldBe(20);
|
||||
engine.Read("BadTag").StatusCode.ShouldBe(0x80020000u, "BadInternalError for thrown script");
|
||||
engine.Read("BadTag").Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_maps_to_BadInternalError_without_killing_the_engine()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up, scriptTimeout: TimeSpan.FromMilliseconds(30));
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Hang", DriverDataType.Int32, """
|
||||
var end = Environment.TickCount64 + 5000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
"""),
|
||||
new VirtualTagDefinition("Ok", DriverDataType.Int32, """return 42;"""),
|
||||
]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Hang").StatusCode.ShouldBe(0x80020000u);
|
||||
engine.Read("Ok").Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribers_receive_engine_emitted_changes()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value + 100;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var received = new List<DataValueSnapshot>();
|
||||
using var sub = engine.Subscribe("Out", (p, v) => received.Add(v));
|
||||
|
||||
up.Push("In", 5);
|
||||
await WaitForConditionAsync(() => received.Count >= 1);
|
||||
|
||||
received[^1].Value.ShouldBe(105);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Historize_flag_routes_to_history_writer()
|
||||
{
|
||||
var recorded = new List<(string, DataValueSnapshot)>();
|
||||
var history = new TestHistory(recorded);
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up, history);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("H", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("In").Value + 1;""", Historize: true),
|
||||
new VirtualTagDefinition("NoH", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("In").Value - 1;""", Historize: false),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
recorded.Select(p => p.Item1).ShouldContain("H");
|
||||
recorded.Select(p => p.Item1).ShouldNotContain("NoH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Change_driven_false_ignores_upstream_push()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Manual", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("In").Value * 10;""",
|
||||
ChangeTriggered: false)]);
|
||||
|
||||
// Initial eval seeds the value.
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Manual").Value.ShouldBe(10);
|
||||
|
||||
// Upstream change fires but change-driven is off — no recompute.
|
||||
up.Push("In", 99);
|
||||
await Task.Delay(100);
|
||||
engine.Read("Manual").Value.ShouldBe(10, "change-driven=false ignores upstream deltas");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reload_replaces_existing_tags_and_resubscribes_cleanly()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("A", 1);
|
||||
up.Set("B", 2);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value * 2;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("T").Value.ShouldBe(2);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
// Reload — T now depends on B instead of A.
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value * 3;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("T").Value.ShouldBe(6);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1, "previous subscription on A must be disposed");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_releases_upstream_subscriptions()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("A", 1);
|
||||
var engine = Build(up);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value;""")]);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
engine.Dispose();
|
||||
up.ActiveSubscriptionCount.ShouldBe(0);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_within_script_updates_target_and_triggers_observers()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 5);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Target", DriverDataType.Int32,
|
||||
"""return 0;""", ChangeTriggered: false), // placeholder value, operator-written via SetVirtualTag
|
||||
new VirtualTagDefinition("Driver", DriverDataType.Int32,
|
||||
"""
|
||||
var v = (int)ctx.GetTag("In").Value;
|
||||
ctx.SetVirtualTag("Target", v * 100);
|
||||
return v;
|
||||
"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("Target").Value.ShouldBe(500);
|
||||
engine.Read("Driver").Value.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Type_coercion_from_script_double_to_config_int32()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 3.7);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Rounded", DriverDataType.Int32,
|
||||
"""return (double)ctx.GetTag("In").Value;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("Rounded").Value.ShouldBe(4, "Convert.ToInt32 rounds 3.7 to 4");
|
||||
}
|
||||
|
||||
private static async Task WaitForConditionAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (cond()) return;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
throw new TimeoutException("Condition did not become true in time");
|
||||
}
|
||||
|
||||
private sealed class TestHistory : IHistoryWriter
|
||||
{
|
||||
private readonly List<(string, DataValueSnapshot)> _buf;
|
||||
public TestHistory(List<(string, DataValueSnapshot)> buf) => _buf = buf;
|
||||
public void Record(string path, DataValueSnapshot value)
|
||||
{
|
||||
lock (_buf) { _buf.Add((path, value)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the IReadable + ISubscribable adapter that DriverNodeManager dispatches
|
||||
/// to for NodeSource.Virtual per ADR-002. Key contract: OPC UA clients see virtual
|
||||
/// tags via the same capability interfaces as driver tags, so dispatch stays
|
||||
/// source-agnostic.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VirtualTagSourceTests
|
||||
{
|
||||
private static (VirtualTagEngine engine, VirtualTagSource source, FakeUpstream up) Build()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 10);
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var engine = new VirtualTagEngine(up, new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value * 2;""")]);
|
||||
return (engine, new VirtualTagSource(engine), up);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_engine_cached_values()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await source.ReadAsync(["Out"], TestContext.Current.CancellationToken);
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].Value.ShouldBe(20);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_unknown_path_returns_Bad_quality()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
var results = await source.ReadAsync(["NoSuchTag"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0x80340000u);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_fires_initial_data_callback()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<DataChangeEventArgs>();
|
||||
source.OnDataChange += (_, e) => events.Add(e);
|
||||
|
||||
var handle = await source.SubscribeAsync(["Out"], TimeSpan.FromMilliseconds(100),
|
||||
TestContext.Current.CancellationToken);
|
||||
handle.ShouldNotBeNull();
|
||||
|
||||
// Per OPC UA convention, initial-data callback fires on subscribe.
|
||||
events.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
events[0].FullReference.ShouldBe("Out");
|
||||
events[0].Snapshot.Value.ShouldBe(20);
|
||||
|
||||
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_fires_on_upstream_change_via_engine_cascade()
|
||||
{
|
||||
var (engine, source, up) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<DataChangeEventArgs>();
|
||||
source.OnDataChange += (_, e) => events.Add(e);
|
||||
var handle = await source.SubscribeAsync(["Out"], TimeSpan.Zero,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var initialCount = events.Count;
|
||||
up.Push("In", 50);
|
||||
|
||||
// Wait for the cascade.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (DateTime.UtcNow < deadline && events.Count <= initialCount) await Task.Delay(25);
|
||||
|
||||
events.Count.ShouldBeGreaterThan(initialCount);
|
||||
events[^1].Snapshot.Value.ShouldBe(100);
|
||||
|
||||
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_stops_further_events()
|
||||
{
|
||||
var (engine, source, up) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<DataChangeEventArgs>();
|
||||
source.OnDataChange += (_, e) => events.Add(e);
|
||||
var handle = await source.SubscribeAsync(["Out"], TimeSpan.Zero,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
var countAfterUnsub = events.Count;
|
||||
|
||||
up.Push("In", 99);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Count.ShouldBe(countAfterUnsub, "Unsubscribe must stop OnDataChange emissions");
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_arguments_rejected()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.ReadAsync(null!, TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.SubscribeAsync(null!, TimeSpan.Zero, TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.UnsubscribeAsync(null!, TestContext.Current.CancellationToken));
|
||||
engine.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user