diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index d43c521..779b37f 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -54,6 +54,7 @@
+
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/HoconLoaderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/HoconLoaderTests.cs
new file mode 100644
index 0000000..2e28aa2
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/HoconLoaderTests.cs
@@ -0,0 +1,51 @@
+using Akka.Configuration;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
+
+public sealed class HoconLoaderTests
+{
+ [Fact]
+ public void LoadBaseConfig_returns_nonempty_string()
+ {
+ var hocon = HoconLoader.LoadBaseConfig();
+ hocon.ShouldNotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public void Base_config_parses_to_cluster_provider()
+ {
+ var cfg = ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig());
+ cfg.GetString("akka.actor.provider").ShouldBe("cluster");
+ }
+
+ [Fact]
+ public void Split_brain_resolver_is_keep_oldest()
+ {
+ var cfg = ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig());
+ cfg.GetString("akka.cluster.split-brain-resolver.active-strategy").ShouldBe("keep-oldest");
+ }
+
+ [Fact]
+ public void Stable_after_is_15_seconds()
+ {
+ var cfg = ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig());
+ cfg.GetTimeSpan("akka.cluster.split-brain-resolver.stable-after")
+ .ShouldBe(TimeSpan.FromSeconds(15));
+ }
+
+ [Fact]
+ public void Failure_detector_threshold_is_10()
+ {
+ var cfg = ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig());
+ cfg.GetDouble("akka.cluster.failure-detector.threshold").ShouldBe(10.0);
+ }
+
+ [Fact]
+ public void Opcua_synchronized_dispatcher_is_pinned()
+ {
+ var cfg = ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig());
+ cfg.GetString("opcua-synchronized-dispatcher.type").ShouldBe("PinnedDispatcher");
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleParserTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleParserTests.cs
new file mode 100644
index 0000000..8b82671
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/RoleParserTests.cs
@@ -0,0 +1,52 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
+
+public sealed class RoleParserTests
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Empty_input_yields_empty_array(string? raw)
+ {
+ RoleParser.Parse(raw).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Single_role_admin()
+ {
+ RoleParser.Parse("admin").ShouldBe(new[] { "admin" });
+ }
+
+ [Fact]
+ public void Two_roles_csv()
+ {
+ RoleParser.Parse("admin,driver").ShouldBe(new[] { "admin", "driver" });
+ }
+
+ [Fact]
+ public void Whitespace_tolerant()
+ {
+ RoleParser.Parse(" admin , driver ").ShouldBe(new[] { "admin", "driver" });
+ }
+
+ [Fact]
+ public void Case_insensitive_normalizes_to_lower()
+ {
+ RoleParser.Parse("ADMIN,Driver").ShouldBe(new[] { "admin", "driver" });
+ }
+
+ [Fact]
+ public void Duplicate_roles_deduped()
+ {
+ RoleParser.Parse("admin,admin,driver").ShouldBe(new[] { "admin", "driver" });
+ }
+
+ [Fact]
+ public void Unknown_role_throws()
+ {
+ Should.Throw(() => RoleParser.Parse("admin,master"));
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests.csproj b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests.csproj
new file mode 100644
index 0000000..8d4d4b5
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Cluster.Tests
+ true
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+