14 bite-sized tasks (TDD pattern) covering: - Commons foundation: POCOs, serializer, validator - Runtime adoption: OpcUaDataConnection + DeploymentManagerActor swap - UI build: <OpcUaEndpointEditor> + DataConnectionForm rewrite - Verification: build/test green + Docker browser smoke + push Tasks #45-#58 created with blocking dependencies; companion .tasks.json sidecar persists the plan for executing-plans skill.
63 KiB
OPC UA Endpoint Config Model & Form Refactor — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Extract OPC UA endpoint configuration from free-form JSON strings into a strongly-typed OpcUaEndpointConfig POCO with a validator and serializer in Commons; consume the model from both the site-side runtime (OpcUaDataConnection) and the central UI (/admin/connections create/edit), rendering typed Bootstrap controls instead of a JSON textarea.
Architecture: New POCO/validator/serializer in ScadaLink.Commons are the single schema. Typed nested JSON (System.Text.Json, camelCase) is the storage shape; a legacy flat string-dict fallback in the serializer keeps existing rows readable. IDataConnection.ConnectAsync keeps its IDictionary<string,string> contract (protocol-agnostic), and OpcUaDataConnection uses OpcUaEndpointConfigSerializer.FromFlatDict internally to get the typed model. A shared <OpcUaEndpointEditor> Blazor component renders the form, used twice (primary + backup).
Tech Stack: C# 12 / .NET 10, System.Text.Json, Blazor Server, Bootstrap 5, xUnit + bUnit + NSubstitute.
Design doc: docs/plans/2026-05-12-opcua-config-model-design.md
Tasks at a glance
- Task 1: Create POCOs (
OpcUaEndpointConfig,OpcUaHeartbeatConfig,OpcUaSecurityMode) + addConnectionConfigtoValidationCategory. - Task 2: TDD
OpcUaEndpointConfigSerializer— write failing tests for Serialize/Deserialize/legacy/ToFlatDict/FromFlatDict. - Task 3: Implement
OpcUaEndpointConfigSerializeruntil tests pass. - Task 4: TDD
OpcUaEndpointConfigValidator— write failing tests for all rules. - Task 5: Implement
OpcUaEndpointConfigValidatoruntil tests pass. - Task 6: Refactor
OpcUaDataConnection.ConnectAsyncto useFromFlatDict. RemoveParseInt/ParseBoolhelpers and the string-fishing ladder. - Task 7: Refactor
DeploymentManagerActor.EnsureDclConnectionsto round-trip JSON → model → flat dict via the serializer (handles both new typed nested JSON and legacy flat dict). - Task 8: TDD
<OpcUaEndpointEditor>Blazor component — bUnit tests for binding, heartbeat toggle, error rendering, legacy banner. - Task 9: Implement
OpcUaEndpointEditor.razoruntil tests pass. - Task 10: TDD
DataConnectionForm.razorrefactor — bUnit tests for invalid-save / valid-save / legacy-load. - Task 11: Refactor
DataConnectionForm.razorto use editor; remove Protocol dropdown; set entityProtocol = "OpcUa"on save. - Task 12: Full solution build + all test suites green.
- Task 13: Docker deploy + browser smoke.
- Task 14: Commit & push.
Task 1: Create POCOs and add ValidationCategory value
Files:
- Create:
src/ScadaLink.Commons/Types/DataConnections/OpcUaSecurityMode.cs - Create:
src/ScadaLink.Commons/Types/DataConnections/OpcUaHeartbeatConfig.cs - Create:
src/ScadaLink.Commons/Types/DataConnections/OpcUaEndpointConfig.cs - Modify:
src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs(addConnectionConfigenum value)
Step 1: Create OpcUaSecurityMode.cs
namespace ScadaLink.Commons.Types.DataConnections;
public enum OpcUaSecurityMode
{
None,
Sign,
SignAndEncrypt
}
Step 2: Create OpcUaHeartbeatConfig.cs
namespace ScadaLink.Commons.Types.DataConnections;
public sealed class OpcUaHeartbeatConfig
{
public string TagPath { get; set; } = "";
public int MaxSilenceSeconds { get; set; } = 30;
}
Step 3: Create OpcUaEndpointConfig.cs
namespace ScadaLink.Commons.Types.DataConnections;
public sealed class OpcUaEndpointConfig
{
// Connection
public string EndpointUrl { get; set; } = "";
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
public bool AutoAcceptUntrustedCerts { get; set; } = true;
// Timing
public int SessionTimeoutMs { get; set; } = 60000;
public int OperationTimeoutMs { get; set; } = 15000;
// Subscription
public int PublishingIntervalMs { get; set; } = 1000;
public int SamplingIntervalMs { get; set; } = 1000;
public int QueueSize { get; set; } = 10;
public int KeepAliveCount { get; set; } = 10;
public int LifetimeCount { get; set; } = 30;
public int MaxNotificationsPerPublish { get; set; } = 100;
// Heartbeat (optional)
public OpcUaHeartbeatConfig? Heartbeat { get; set; }
}
Step 4: Add ConnectionConfig to ValidationCategory
In src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs:50-65, append ConnectionConfig to the enum:
public enum ValidationCategory
{
FlatteningFailure,
NamingCollision,
ScriptCompilation,
AlarmTriggerReference,
ScriptTriggerReference,
ConnectionBinding,
CallTargetNotFound,
ParameterMismatch,
ReturnTypeMismatch,
TriggerOperandType,
OnTriggerScriptNotFound,
CrossCallViolation,
MissingMetadata,
ConnectionConfig
}
Step 5: Build to confirm everything compiles
Run: dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj
Expected: build succeeded, 0 errors.
Step 6: Commit
git add src/ScadaLink.Commons/Types/DataConnections/ src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs
git commit -m "feat(commons): OpcUaEndpointConfig POCOs + ConnectionConfig ValidationCategory"
Task 2: TDD — OpcUaEndpointConfigSerializer tests (failing)
Files:
- Create:
tests/ScadaLink.Commons.Tests/Types/DataConnections/OpcUaEndpointConfigSerializerTests.cs
Step 1: Write the failing test file
using ScadaLink.Commons.Serialization;
using ScadaLink.Commons.Types.DataConnections;
namespace ScadaLink.Commons.Tests.Types.DataConnections;
public class OpcUaEndpointConfigSerializerTests
{
[Fact]
public void Serialize_TypedRoundtrip_PreservesAllFields()
{
var original = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://plant-a:4840",
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
AutoAcceptUntrustedCerts = false,
SessionTimeoutMs = 90000,
OperationTimeoutMs = 20000,
PublishingIntervalMs = 500,
SamplingIntervalMs = 250,
QueueSize = 50,
KeepAliveCount = 5,
LifetimeCount = 15,
MaxNotificationsPerPublish = 200,
Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Sensors.HB", MaxSilenceSeconds = 60 }
};
var json = OpcUaEndpointConfigSerializer.Serialize(original);
var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json);
Assert.False(isLegacy);
Assert.Equal(original.EndpointUrl, round.EndpointUrl);
Assert.Equal(original.SecurityMode, round.SecurityMode);
Assert.Equal(original.AutoAcceptUntrustedCerts, round.AutoAcceptUntrustedCerts);
Assert.Equal(original.SessionTimeoutMs, round.SessionTimeoutMs);
Assert.Equal(original.OperationTimeoutMs, round.OperationTimeoutMs);
Assert.Equal(original.PublishingIntervalMs, round.PublishingIntervalMs);
Assert.Equal(original.SamplingIntervalMs, round.SamplingIntervalMs);
Assert.Equal(original.QueueSize, round.QueueSize);
Assert.Equal(original.KeepAliveCount, round.KeepAliveCount);
Assert.Equal(original.LifetimeCount, round.LifetimeCount);
Assert.Equal(original.MaxNotificationsPerPublish, round.MaxNotificationsPerPublish);
Assert.NotNull(round.Heartbeat);
Assert.Equal("Sensors.HB", round.Heartbeat!.TagPath);
Assert.Equal(60, round.Heartbeat.MaxSilenceSeconds);
}
[Fact]
public void Serialize_UsesCamelCase()
{
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
var json = OpcUaEndpointConfigSerializer.Serialize(config);
Assert.Contains("\"endpointUrl\"", json);
Assert.DoesNotContain("\"EndpointUrl\"", json);
}
[Fact]
public void Serialize_SecurityModeAsCamelCaseString()
{
var config = new OpcUaEndpointConfig { SecurityMode = OpcUaSecurityMode.SignAndEncrypt };
var json = OpcUaEndpointConfigSerializer.Serialize(config);
Assert.Contains("\"securityMode\":\"signAndEncrypt\"", json);
}
[Fact]
public void Deserialize_NullOrEmpty_ReturnsDefaults()
{
var (config1, legacy1) = OpcUaEndpointConfigSerializer.Deserialize(null);
var (config2, legacy2) = OpcUaEndpointConfigSerializer.Deserialize("");
var (config3, legacy3) = OpcUaEndpointConfigSerializer.Deserialize(" ");
Assert.False(legacy1);
Assert.False(legacy2);
Assert.False(legacy3);
Assert.Equal("", config1.EndpointUrl);
Assert.Equal(60000, config1.SessionTimeoutMs);
Assert.Null(config1.Heartbeat);
}
[Fact]
public void Deserialize_LegacyFlatDict_IsLegacyTrue_PopulatesFields()
{
// The legacy shape that DeploymentManagerActor produced from raw JSON before this refactor.
var legacyJson = """
{
"endpoint": "opc.tcp://legacy:4840",
"SessionTimeoutMs": "45000",
"SamplingIntervalMs": "500",
"SecurityMode": "Sign",
"AutoAcceptUntrustedCerts": "false",
"HeartbeatTagPath": "Old.Heartbeat",
"HeartbeatMaxSilence": "20"
}
""";
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
Assert.True(isLegacy);
Assert.Equal("opc.tcp://legacy:4840", config.EndpointUrl);
Assert.Equal(45000, config.SessionTimeoutMs);
Assert.Equal(500, config.SamplingIntervalMs);
Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode);
Assert.False(config.AutoAcceptUntrustedCerts);
Assert.NotNull(config.Heartbeat);
Assert.Equal("Old.Heartbeat", config.Heartbeat!.TagPath);
Assert.Equal(20, config.Heartbeat.MaxSilenceSeconds);
}
[Fact]
public void Deserialize_LegacyWithEndpointUrlPascalKey_Works()
{
// Some pre-refactor configs used "EndpointUrl" instead of "endpoint".
var legacyJson = """{"EndpointUrl":"opc.tcp://x:4840"}""";
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
Assert.True(isLegacy);
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
}
[Fact]
public void ToFlatDict_OmitsNullHeartbeat()
{
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.Equal("opc.tcp://x:4840", dict["endpoint"]);
Assert.Equal("60000", dict["SessionTimeoutMs"]);
Assert.Equal("None", dict["SecurityMode"]);
Assert.Equal("True", dict["AutoAcceptUntrustedCerts"]);
Assert.False(dict.ContainsKey("HeartbeatTagPath"));
Assert.False(dict.ContainsKey("HeartbeatMaxSilence"));
}
[Fact]
public void ToFlatDict_IncludesHeartbeat_WhenSet()
{
var config = new OpcUaEndpointConfig
{
EndpointUrl = "opc.tcp://x:4840",
Heartbeat = new OpcUaHeartbeatConfig { TagPath = "HB.Tag", MaxSilenceSeconds = 45 }
};
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
Assert.Equal("HB.Tag", dict["HeartbeatTagPath"]);
Assert.Equal("45", dict["HeartbeatMaxSilence"]);
}
[Fact]
public void FromFlatDict_RoundTripsAllKeys()
{
var dict = new Dictionary<string, string>
{
["endpoint"] = "opc.tcp://x:4840",
["SessionTimeoutMs"] = "30000",
["SecurityMode"] = "Sign",
["HeartbeatTagPath"] = "Hb",
["HeartbeatMaxSilence"] = "15"
};
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
Assert.Equal(30000, config.SessionTimeoutMs);
Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode);
Assert.NotNull(config.Heartbeat);
Assert.Equal("Hb", config.Heartbeat!.TagPath);
}
}
Step 2: Run the tests to verify failure
Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigSerializerTests
Expected: build error — type OpcUaEndpointConfigSerializer does not exist.
Step 3: Commit the failing tests
git add tests/ScadaLink.Commons.Tests/Types/DataConnections/
git commit -m "test(commons): failing tests for OpcUaEndpointConfigSerializer"
Task 3: Implement OpcUaEndpointConfigSerializer
Files:
- Create:
src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs
Step 1: Implement the serializer
using System.Text.Json;
using System.Text.Json.Serialization;
using ScadaLink.Commons.Types.DataConnections;
namespace ScadaLink.Commons.Serialization;
/// <summary>
/// Serializes <see cref="OpcUaEndpointConfig"/> to/from the typed nested JSON
/// shape stored in <c>DataConnection.PrimaryConfiguration</c> / <c>BackupConfiguration</c>.
/// On read, falls back to the legacy flat string-dict shape for pre-refactor rows
/// and returns IsLegacy=true so the form can prompt the user to re-save.
/// </summary>
public static class OpcUaEndpointConfigSerializer
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public static string Serialize(OpcUaEndpointConfig config)
=> JsonSerializer.Serialize(config, JsonOpts);
public static (OpcUaEndpointConfig Config, bool IsLegacy) Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return (new OpcUaEndpointConfig(), false);
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("endpointUrl", out _))
{
var typed = JsonSerializer.Deserialize<OpcUaEndpointConfig>(json, JsonOpts);
if (typed != null)
return (typed, false);
}
}
catch (JsonException) { /* fall through to legacy */ }
return (LoadLegacy(json!), IsLegacy: true);
}
/// <summary>
/// Flattens the typed config to the IDictionary<string,string> shape that
/// IDataConnection.ConnectAsync expects. Keys match the historical convention
/// used by OpcUaDataConnection so the adapter can keep that interface.
/// </summary>
public static IDictionary<string, string> ToFlatDict(OpcUaEndpointConfig config)
{
var dict = new Dictionary<string, string>
{
["endpoint"] = config.EndpointUrl,
["SecurityMode"] = config.SecurityMode.ToString(),
["AutoAcceptUntrustedCerts"] = config.AutoAcceptUntrustedCerts.ToString(),
["SessionTimeoutMs"] = config.SessionTimeoutMs.ToString(),
["OperationTimeoutMs"] = config.OperationTimeoutMs.ToString(),
["PublishingIntervalMs"] = config.PublishingIntervalMs.ToString(),
["SamplingIntervalMs"] = config.SamplingIntervalMs.ToString(),
["QueueSize"] = config.QueueSize.ToString(),
["KeepAliveCount"] = config.KeepAliveCount.ToString(),
["LifetimeCount"] = config.LifetimeCount.ToString(),
["MaxNotificationsPerPublish"] = config.MaxNotificationsPerPublish.ToString(),
};
if (config.Heartbeat is { } hb)
{
dict["HeartbeatTagPath"] = hb.TagPath;
dict["HeartbeatMaxSilence"] = hb.MaxSilenceSeconds.ToString();
}
return dict;
}
public static OpcUaEndpointConfig FromFlatDict(IDictionary<string, string> dict)
{
var c = new OpcUaEndpointConfig
{
EndpointUrl = dict.TryGetValue("endpoint", out var ep) ? ep
: dict.TryGetValue("EndpointUrl", out var ep2) ? ep2 : "",
SecurityMode = Enum.TryParse<OpcUaSecurityMode>(
dict.GetValueOrDefault("SecurityMode"), ignoreCase: true, out var sm)
? sm : OpcUaSecurityMode.None,
AutoAcceptUntrustedCerts = ParseBool(dict, "AutoAcceptUntrustedCerts", true),
SessionTimeoutMs = ParseInt(dict, "SessionTimeoutMs", 60000),
OperationTimeoutMs = ParseInt(dict, "OperationTimeoutMs", 15000),
PublishingIntervalMs = ParseInt(dict, "PublishingIntervalMs", 1000),
SamplingIntervalMs = ParseInt(dict, "SamplingIntervalMs", 1000),
QueueSize = ParseInt(dict, "QueueSize", 10),
KeepAliveCount = ParseInt(dict, "KeepAliveCount", 10),
LifetimeCount = ParseInt(dict, "LifetimeCount", 30),
MaxNotificationsPerPublish = ParseInt(dict, "MaxNotificationsPerPublish", 100),
};
if (dict.TryGetValue("HeartbeatTagPath", out var hbPath)
&& !string.IsNullOrWhiteSpace(hbPath))
{
c.Heartbeat = new OpcUaHeartbeatConfig
{
TagPath = hbPath,
MaxSilenceSeconds = ParseInt(dict, "HeartbeatMaxSilence", 30)
};
}
return c;
}
private static OpcUaEndpointConfig LoadLegacy(string json)
{
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json)
?? new Dictionary<string, string>();
return FromFlatDict(dict);
}
private static int ParseInt(IDictionary<string, string> d, string key, int defaultValue)
=> d.TryGetValue(key, out var s) && int.TryParse(s, out var v) ? v : defaultValue;
private static bool ParseBool(IDictionary<string, string> d, string key, bool defaultValue)
=> d.TryGetValue(key, out var s) && bool.TryParse(s, out var v) ? v : defaultValue;
}
Step 2: Run tests, verify they pass
Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigSerializerTests
Expected: all 8 tests pass.
Step 3: Commit
git add src/ScadaLink.Commons/Serialization/
git commit -m "feat(commons): OpcUaEndpointConfigSerializer with legacy fallback + flat-dict interop"
Task 4: TDD — OpcUaEndpointConfigValidator tests (failing)
Files:
- Create:
tests/ScadaLink.Commons.Tests/Validators/OpcUaEndpointConfigValidatorTests.cs
Step 1: Write the failing test file
using ScadaLink.Commons.Types.DataConnections;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.Commons.Validators;
namespace ScadaLink.Commons.Tests.Validators;
public class OpcUaEndpointConfigValidatorTests
{
private static OpcUaEndpointConfig Valid() => new()
{
EndpointUrl = "opc.tcp://plant-a:4840",
// Defaults satisfy the spec: Lifetime(30) >= 3 * KeepAlive(10).
};
[Fact]
public void Validate_DefaultsWithValidUrl_IsValid()
{
var result = OpcUaEndpointConfigValidator.Validate(Valid());
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public void Validate_MissingEndpointUrl_Fails()
{
var c = Valid();
c.EndpointUrl = "";
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.False(r.IsValid);
Assert.Contains(r.Errors, e =>
e.EntityName == "EndpointUrl"
&& e.Category == ValidationCategory.ConnectionConfig
&& e.Message.Contains("required", StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData("http://x")]
[InlineData("opc.tcp://")]
[InlineData("not a url")]
public void Validate_BadEndpointUrl_Fails(string url)
{
var c = Valid();
c.EndpointUrl = url;
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.False(r.IsValid);
Assert.Contains(r.Errors, e => e.EntityName == "EndpointUrl");
}
[Fact]
public void Validate_LifetimeLessThanThreeTimesKeepAlive_Fails()
{
var c = Valid();
c.KeepAliveCount = 10;
c.LifetimeCount = 29; // 3*10 = 30; 29 < 30 → invalid
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.False(r.IsValid);
Assert.Contains(r.Errors, e => e.EntityName == "LifetimeCount");
}
[Theory]
[InlineData(nameof(OpcUaEndpointConfig.SessionTimeoutMs))]
[InlineData(nameof(OpcUaEndpointConfig.OperationTimeoutMs))]
[InlineData(nameof(OpcUaEndpointConfig.PublishingIntervalMs))]
[InlineData(nameof(OpcUaEndpointConfig.SamplingIntervalMs))]
public void Validate_NonPositiveTiming_Fails(string field)
{
var c = Valid();
typeof(OpcUaEndpointConfig).GetProperty(field)!.SetValue(c, 0);
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.False(r.IsValid);
Assert.Contains(r.Errors, e => e.EntityName == field);
}
[Fact]
public void Validate_QueueSizeZero_Fails()
{
var c = Valid();
c.QueueSize = 0;
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.Contains(r.Errors, e => e.EntityName == "QueueSize");
}
[Fact]
public void Validate_HeartbeatEnabledNoTagPath_Fails()
{
var c = Valid();
c.Heartbeat = new OpcUaHeartbeatConfig { TagPath = "", MaxSilenceSeconds = 30 };
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.TagPath");
}
[Fact]
public void Validate_HeartbeatNonPositiveSilence_Fails()
{
var c = Valid();
c.Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Hb", MaxSilenceSeconds = 0 };
var r = OpcUaEndpointConfigValidator.Validate(c);
Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.MaxSilenceSeconds");
}
[Fact]
public void Validate_FieldPrefix_AppliedToEveryError()
{
var c = Valid();
c.EndpointUrl = "";
c.QueueSize = 0;
var r = OpcUaEndpointConfigValidator.Validate(c, fieldPrefix: "Primary.");
Assert.All(r.Errors, e => Assert.StartsWith("Primary.", e.EntityName!));
Assert.Contains(r.Errors, e => e.EntityName == "Primary.EndpointUrl");
Assert.Contains(r.Errors, e => e.EntityName == "Primary.QueueSize");
}
}
Step 2: Run, verify failure
Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigValidatorTests
Expected: build error — OpcUaEndpointConfigValidator not defined.
Step 3: Commit
git add tests/ScadaLink.Commons.Tests/Validators/
git commit -m "test(commons): failing tests for OpcUaEndpointConfigValidator"
Task 5: Implement OpcUaEndpointConfigValidator
Files:
- Create:
src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs
Step 1: Implement
using ScadaLink.Commons.Types.DataConnections;
using ScadaLink.Commons.Types.Flattening;
namespace ScadaLink.Commons.Validators;
/// <summary>
/// Pure-function validator for <see cref="OpcUaEndpointConfig"/>. Errors carry
/// the offending property name in <see cref="ValidationEntry.EntityName"/>
/// (optionally prefixed, e.g. "Primary.EndpointUrl") so the form can render
/// per-field messages.
/// </summary>
public static class OpcUaEndpointConfigValidator
{
public static ValidationResult Validate(OpcUaEndpointConfig config, string fieldPrefix = "")
{
var errors = new List<ValidationEntry>();
if (string.IsNullOrWhiteSpace(config.EndpointUrl))
errors.Add(Err("EndpointUrl", "Endpoint URL is required."));
else if (!Uri.TryCreate(config.EndpointUrl, UriKind.Absolute, out var uri)
|| uri.Scheme != "opc.tcp"
|| string.IsNullOrEmpty(uri.Host))
errors.Add(Err("EndpointUrl", "Endpoint URL must be a valid opc.tcp:// URI."));
if (config.SessionTimeoutMs <= 0)
errors.Add(Err("SessionTimeoutMs", "Must be > 0."));
if (config.OperationTimeoutMs <= 0)
errors.Add(Err("OperationTimeoutMs", "Must be > 0."));
if (config.PublishingIntervalMs <= 0)
errors.Add(Err("PublishingIntervalMs", "Must be > 0."));
if (config.SamplingIntervalMs <= 0)
errors.Add(Err("SamplingIntervalMs", "Must be > 0."));
if (config.QueueSize < 1)
errors.Add(Err("QueueSize", "Must be ≥ 1."));
if (config.KeepAliveCount < 1)
errors.Add(Err("KeepAliveCount", "Must be ≥ 1."));
if (config.LifetimeCount < config.KeepAliveCount * 3)
errors.Add(Err("LifetimeCount",
"Must be at least 3× KeepAliveCount per OPC UA spec."));
if (config.MaxNotificationsPerPublish < 1)
errors.Add(Err("MaxNotificationsPerPublish", "Must be ≥ 1."));
if (config.Heartbeat is { } hb)
{
if (string.IsNullOrWhiteSpace(hb.TagPath))
errors.Add(Err("Heartbeat.TagPath",
"Tag path is required when heartbeat is enabled."));
if (hb.MaxSilenceSeconds <= 0)
errors.Add(Err("Heartbeat.MaxSilenceSeconds", "Must be > 0."));
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.FromErrors(errors.ToArray());
ValidationEntry Err(string field, string message) =>
ValidationEntry.Error(
ValidationCategory.ConnectionConfig,
message,
entityName: $"{fieldPrefix}{field}");
}
}
Step 2: Run tests, verify pass
Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointConfigValidatorTests
Expected: all 11 tests pass.
Step 3: Commit
git add src/ScadaLink.Commons/Validators/
git commit -m "feat(commons): OpcUaEndpointConfigValidator"
Task 6: Refactor OpcUaDataConnection.ConnectAsync
Files:
- Modify:
src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:44-115 - Verify:
tests/ScadaLink.DataConnectionLayer.Tests/— any existing test that constructs aDictionary<string,string>and passes it toConnectAsyncshould keep working unchanged.
Step 1: Replace ConnectAsync, StartHeartbeatMonitorAsync, and remove ParseInt/ParseBool helpers
In OpcUaDataConnection.cs, replace lines 44-115 with:
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = default)
{
var config = OpcUaEndpointConfigSerializer.FromFlatDict(connectionDetails);
_endpointUrl = string.IsNullOrWhiteSpace(config.EndpointUrl)
? "opc.tcp://localhost:4840"
: config.EndpointUrl;
var options = new OpcUaConnectionOptions(
SessionTimeoutMs: config.SessionTimeoutMs,
OperationTimeoutMs: config.OperationTimeoutMs,
PublishingIntervalMs: config.PublishingIntervalMs,
KeepAliveCount: config.KeepAliveCount,
LifetimeCount: config.LifetimeCount,
MaxNotificationsPerPublish: config.MaxNotificationsPerPublish,
SamplingIntervalMs: config.SamplingIntervalMs,
QueueSize: config.QueueSize,
SecurityMode: config.SecurityMode.ToString(),
AutoAcceptUntrustedCerts: config.AutoAcceptUntrustedCerts);
_status = ConnectionHealth.Connecting;
_client = _clientFactory.Create();
_client.ConnectionLost += OnClientConnectionLost;
await _client.ConnectAsync(_endpointUrl, options, cancellationToken);
_status = ConnectionHealth.Connected;
_disconnectFired = false;
_logger.LogInformation("OPC UA connected to {Endpoint}", _endpointUrl);
await StartHeartbeatMonitorAsync(config.Heartbeat, cancellationToken);
}
private async Task StartHeartbeatMonitorAsync(OpcUaHeartbeatConfig? heartbeat, CancellationToken cancellationToken)
{
if (heartbeat is null || string.IsNullOrWhiteSpace(heartbeat.TagPath))
return;
_staleMonitor?.Dispose();
_staleMonitor = new StaleTagMonitor(TimeSpan.FromSeconds(heartbeat.MaxSilenceSeconds));
_staleMonitor.Stale += () =>
{
_logger.LogWarning("OPC UA heartbeat tag '{Tag}' stale — no update in {Seconds}s",
heartbeat.TagPath, heartbeat.MaxSilenceSeconds);
RaiseDisconnected();
};
try
{
_heartbeatSubscriptionId = await SubscribeAsync(heartbeat.TagPath,
(_, _) => _staleMonitor.OnValueReceived(), cancellationToken);
_staleMonitor.Start();
_logger.LogInformation("OPC UA heartbeat monitor started for '{Tag}' with {Seconds}s max silence",
heartbeat.TagPath, heartbeat.MaxSilenceSeconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to subscribe to heartbeat tag '{Tag}' — stale monitor not active",
heartbeat.TagPath);
_staleMonitor.Dispose();
_staleMonitor = null;
}
}
Delete the two internal static helpers at lines 107-115 (ParseInt, ParseBool). Add using ScadaLink.Commons.Serialization; and using ScadaLink.Commons.Types.DataConnections; at the top.
Step 2: Run DCL test suite — confirm nothing broke
Run: dotnet test tests/ScadaLink.DataConnectionLayer.Tests/ScadaLink.DataConnectionLayer.Tests.csproj
Expected: all green. Existing tests pass Dictionary<string,string> to ConnectAsync; FromFlatDict accepts the same keys (including endpoint and EndpointUrl casing variants).
Step 3: If a test was directly verifying ParseInt/ParseBool behavior, delete it
grep -n "ParseInt\|ParseBool" tests/ScadaLink.DataConnectionLayer.Tests/ -r — if it returns hits, delete those test cases. They were testing internals that no longer exist; the same behavior is now covered by OpcUaEndpointConfigSerializerTests.FromFlatDict_*.
Step 4: Commit
git add src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs
git add tests/ScadaLink.DataConnectionLayer.Tests/ # if any test edits
git commit -m "refactor(dcl): OpcUaDataConnection uses OpcUaEndpointConfig via FromFlatDict"
Task 7: Refactor DeploymentManagerActor.EnsureDclConnections
Files:
- Modify:
src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:411-468 - Verify:
tests/ScadaLink.SiteRuntime.Tests/— particularly any test that hands a sampleFlattenedConfigurationwith a connection JSON.
Why this changes: The current code does dumb JsonDocument.EnumerateObject + prop.Value.ToString() to flatten the connection JSON into a Dictionary<string,string>. With the new typed nested JSON shape (containing a nested heartbeat object), that flattener produces "heartbeat" → "{\"tagPath\":\"...\"}" — broken. The serializer's Deserialize + ToFlatDict does it correctly and also handles the legacy shape.
Step 1: Replace the body of EnsureDclConnections
Replace lines 411-468 with:
/// <summary>
/// Sets up DCL connections from the flattened config (idempotent: tracks created connections).
/// </summary>
private void EnsureDclConnections(string configJson)
{
if (_dclManager == null) return;
try
{
var config = System.Text.Json.JsonSerializer.Deserialize<Commons.Types.Flattening.FlattenedConfiguration>(configJson);
if (config?.Connections == null) return;
foreach (var (name, connConfig) in config.Connections)
{
if (_createdConnections.Contains(name))
continue;
var primaryDetails = FlattenConnectionConfig(connConfig.Protocol, connConfig.ConfigurationJson);
var backupDetails = string.IsNullOrEmpty(connConfig.BackupConfigurationJson)
? null
: FlattenConnectionConfig(connConfig.Protocol, connConfig.BackupConfigurationJson);
_dclManager.Tell(new Commons.Messages.DataConnection.CreateConnectionCommand(
name, connConfig.Protocol, primaryDetails, backupDetails, connConfig.FailoverRetryCount));
_createdConnections.Add(name);
_logger.LogInformation(
"Created DCL connection {Connection} (protocol={Protocol})",
name, connConfig.Protocol);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse flattened config for DCL connections");
}
}
private static IDictionary<string, string> FlattenConnectionConfig(string protocol, string? json)
{
if (string.IsNullOrEmpty(json))
return new Dictionary<string, string>();
if (string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase))
{
var (config, _) = Commons.Serialization.OpcUaEndpointConfigSerializer.Deserialize(json);
return Commons.Serialization.OpcUaEndpointConfigSerializer.ToFlatDict(config);
}
// Fallback: assume legacy flat-dict shape for any future / unknown protocol.
try
{
var dict = new Dictionary<string, string>();
using var doc = System.Text.Json.JsonDocument.Parse(json);
foreach (var prop in doc.RootElement.EnumerateObject())
dict[prop.Name] = prop.Value.ToString();
return dict;
}
catch
{
return new Dictionary<string, string>();
}
}
Step 2: Run SiteRuntime tests
Run: dotnet test tests/ScadaLink.SiteRuntime.Tests/ScadaLink.SiteRuntime.Tests.csproj
Expected: green. Any existing test passing legacy-style JSON for an OPC UA connection still works because Deserialize handles both shapes.
Step 3: Commit
git add src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs
git commit -m "refactor(site-runtime): route OPC UA connection JSON through serializer"
Task 8: TDD — <OpcUaEndpointEditor> Blazor component tests (failing)
Files:
- Create:
tests/ScadaLink.CentralUI.Tests/Forms/OpcUaEndpointEditorTests.cs
Step 1: Write the failing tests
using Bunit;
using ScadaLink.Commons.Types.DataConnections;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.Commons.Validators;
using OpcUaEndpointEditor = ScadaLink.CentralUI.Components.Forms.OpcUaEndpointEditor;
namespace ScadaLink.CentralUI.Tests.Forms;
public class OpcUaEndpointEditorTests : BunitContext
{
[Fact]
public void Renders_All_Four_Section_Labels()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p
.Add(c => c.Config, config)
.Add(c => c.Title, "Primary Endpoint"));
Assert.Contains("Primary Endpoint", cut.Markup);
Assert.Contains("Timing", cut.Markup);
Assert.Contains("Subscription", cut.Markup);
Assert.Contains("Heartbeat", cut.Markup);
}
[Fact]
public void Binding_MutatesPassedConfigInstance()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
cut.Find("input[type='text']").Change("opc.tcp://new-host:4840");
Assert.Equal("opc.tcp://new-host:4840", config.EndpointUrl);
}
[Fact]
public void EnableHeartbeat_CreatesSubObject()
{
var config = new OpcUaEndpointConfig();
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
Assert.Null(config.Heartbeat);
cut.FindAll("button").First(b => b.TextContent.Contains("Enable Heartbeat")).Click();
Assert.NotNull(config.Heartbeat);
}
[Fact]
public void RemoveHeartbeat_NullsSubObject()
{
var config = new OpcUaEndpointConfig
{
Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Hb", MaxSilenceSeconds = 30 }
};
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, config));
cut.FindAll("button").First(b => b.TextContent.Contains("Remove Heartbeat")).Click();
Assert.Null(config.Heartbeat);
}
[Fact]
public void Errors_Parameter_RendersPerFieldRedText()
{
var config = new OpcUaEndpointConfig { EndpointUrl = "" };
var errors = OpcUaEndpointConfigValidator.Validate(config, "Primary.");
var cut = Render<OpcUaEndpointEditor>(p => p
.Add(c => c.Config, config)
.Add(c => c.Errors, errors));
Assert.Contains("Endpoint URL is required.", cut.Markup);
Assert.Contains("text-danger", cut.Markup);
}
[Fact]
public void IsLegacy_True_RendersWarningBanner()
{
var cut = Render<OpcUaEndpointEditor>(p => p
.Add(c => c.Config, new OpcUaEndpointConfig())
.Add(c => c.IsLegacy, true));
Assert.Contains("alert-warning", cut.Markup);
Assert.Contains("migrated from a legacy format", cut.Markup);
}
}
Step 2: Run, verify failure
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointEditorTests
Expected: build error — OpcUaEndpointEditor does not exist.
Step 3: Commit
git add tests/ScadaLink.CentralUI.Tests/Forms/
git commit -m "test(ui): failing bUnit tests for OpcUaEndpointEditor"
Task 9: Implement <OpcUaEndpointEditor> Blazor component
Files:
- Create:
src/ScadaLink.CentralUI/Components/Forms/OpcUaEndpointEditor.razor
Step 1: Implement the component
@namespace ScadaLink.CentralUI.Components.Forms
@using ScadaLink.Commons.Types.DataConnections
@using ScadaLink.Commons.Types.Flattening
<div class="opcua-endpoint-editor">
<h6 class="text-muted border-bottom pb-1">@Title</h6>
@if (IsLegacy)
{
<div class="alert alert-warning py-1 small mb-2">
This connection was migrated from a legacy format.
Review the settings and Save to update.
</div>
}
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Endpoint URL</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.EndpointUrl"
placeholder="opc.tcp://host:4840" />
@RenderFieldError("EndpointUrl")
</div>
<div class="col-md-3">
<label class="form-label small">Security Mode</label>
<select class="form-select form-select-sm" @bind="Config.SecurityMode">
<option value="@OpcUaSecurityMode.None">None</option>
<option value="@OpcUaSecurityMode.Sign">Sign</option>
<option value="@OpcUaSecurityMode.SignAndEncrypt">Sign & Encrypt</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-autoaccept")"
@bind="Config.AutoAcceptUntrustedCerts" />
<label class="form-check-label small"
for="@($"{IdPrefix}-autoaccept")">Auto-accept certs</label>
</div>
</div>
</div>
<div class="text-muted small mt-2 mb-1">Timing</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Session timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SessionTimeoutMs" min="1" />
@RenderFieldError("SessionTimeoutMs")
</div>
<div class="col-md-3">
<label class="form-label small">Operation timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.OperationTimeoutMs" min="1" />
@RenderFieldError("OperationTimeoutMs")
</div>
</div>
<div class="text-muted small mt-2 mb-1">Subscription</div>
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Publishing interval (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.PublishingIntervalMs" min="1" />
@RenderFieldError("PublishingIntervalMs")
</div>
<div class="col-md-3">
<label class="form-label small">Sampling interval (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.SamplingIntervalMs" min="1" />
@RenderFieldError("SamplingIntervalMs")
</div>
<div class="col-md-2">
<label class="form-label small">Queue size</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.QueueSize" min="1" />
@RenderFieldError("QueueSize")
</div>
<div class="col-md-2">
<label class="form-label small">Keep-alive count</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.KeepAliveCount" min="1" />
@RenderFieldError("KeepAliveCount")
</div>
<div class="col-md-2">
<label class="form-label small">Lifetime count</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.LifetimeCount" min="1" />
@RenderFieldError("LifetimeCount")
</div>
<div class="col-md-3">
<label class="form-label small">Max notifications / publish</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.MaxNotificationsPerPublish" min="1" />
@RenderFieldError("MaxNotificationsPerPublish")
</div>
</div>
<div class="text-muted small mt-2 mb-1">Heartbeat</div>
@if (Config.Heartbeat is null)
{
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="EnableHeartbeat">Enable Heartbeat</button>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Tag path</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.Heartbeat.TagPath"
placeholder="Sensors.Heartbeat" />
@RenderFieldError("Heartbeat.TagPath")
</div>
<div class="col-md-3">
<label class="form-label small">Max silence (s)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.Heartbeat.MaxSilenceSeconds" min="1" />
@RenderFieldError("Heartbeat.MaxSilenceSeconds")
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="() => Config.Heartbeat = null">
Remove Heartbeat
</button>
</div>
</div>
}
</div>
@code {
[Parameter, EditorRequired] public OpcUaEndpointConfig Config { get; set; } = default!;
[Parameter] public string Title { get; set; } = "Endpoint";
[Parameter] public string IdPrefix { get; set; } = "endpoint";
[Parameter] public bool IsLegacy { get; set; }
[Parameter] public ValidationResult? Errors { get; set; }
private void EnableHeartbeat() =>
Config.Heartbeat = new OpcUaHeartbeatConfig();
private RenderFragment? RenderFieldError(string field)
{
var match = Errors?.Errors.FirstOrDefault(e =>
e.EntityName != null
&& (e.EntityName == field || e.EntityName.EndsWith("." + field)));
return match is null
? null
: @<div class="text-danger small">@match.Message</div>;
}
}
Step 2: Run the editor tests, verify pass
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~OpcUaEndpointEditorTests
Expected: all 6 tests pass.
Step 3: Commit
git add src/ScadaLink.CentralUI/Components/Forms/
git commit -m "feat(ui): OpcUaEndpointEditor Blazor component"
Task 10: TDD — DataConnectionForm refactor tests (failing/updating)
Files:
- Create or modify:
tests/ScadaLink.CentralUI.Tests/DataConnectionFormTests.cs
Before writing, run: ls tests/ScadaLink.CentralUI.Tests/ | grep -i "DataConnection". If a test file already exists, modify it; otherwise create new.
Step 1: Write the failing tests
using System.Security.Claims;
using System.Text.Json;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using DataConnectionForm = ScadaLink.CentralUI.Components.Pages.Admin.DataConnectionForm;
namespace ScadaLink.CentralUI.Tests;
public class DataConnectionFormTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
public DataConnectionFormTests()
{
Services.AddSingleton(_siteRepo);
AddTestAuth();
var sites = new List<Site> { new("Plant-A", "plant-a") { Id = 1 } };
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites));
}
private void AddTestAuth()
{
var claims = new[]
{
new Claim("Username", "tester"),
new Claim(ClaimTypes.Role, "Admin")
};
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
}
[Fact]
public void NoProtocolDropdown_IsRendered()
{
var cut = Render<DataConnectionForm>(p => p.Add(f => f.SiteId, 1));
Assert.DoesNotContain("Custom", cut.Markup);
// OPC UA security mode dropdown does exist, but the protocol dropdown's
// distinctive labels (e.g. an option saying "OPC UA" directly) should be gone.
var labels = cut.FindAll("label").Select(l => l.TextContent.Trim()).ToList();
Assert.DoesNotContain(labels, l => l == "Protocol");
}
[Fact]
public async Task Save_InvalidPrimaryUrl_DoesNotCallRepo()
{
var cut = Render<DataConnectionForm>(p => p.Add(f => f.SiteId, 1));
cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true)
.Change("not-a-url");
// Name field
cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder") is null
|| !i.GetAttribute("placeholder")!.StartsWith("opc.tcp"))
.Change("My Connection");
await cut.FindAll("button")
.First(b => b.TextContent.Trim() == "Save").ClickAsync(new());
await _siteRepo.DidNotReceive().AddDataConnectionAsync(Arg.Any<DataConnection>());
Assert.Contains("Endpoint URL must be a valid", cut.Markup);
}
[Fact]
public async Task Save_ValidConfig_PersistsTypedJsonAndProtocolOpcUa()
{
DataConnection? captured = null;
await _siteRepo.AddDataConnectionAsync(
Arg.Do<DataConnection>(d => captured = d));
var cut = Render<DataConnectionForm>(p => p.Add(f => f.SiteId, 1));
// Name
cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder") is null
|| !i.GetAttribute("placeholder")!.StartsWith("opc.tcp"))
.Change("PLC-1");
// Endpoint URL
cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder")?.StartsWith("opc.tcp") == true)
.Change("opc.tcp://plant-a:4840");
await cut.FindAll("button")
.First(b => b.TextContent.Trim() == "Save").ClickAsync(new());
Assert.NotNull(captured);
Assert.Equal("OpcUa", captured!.Protocol);
Assert.NotNull(captured.PrimaryConfiguration);
using var doc = JsonDocument.Parse(captured.PrimaryConfiguration!);
Assert.Equal("opc.tcp://plant-a:4840",
doc.RootElement.GetProperty("endpointUrl").GetString());
Assert.Equal(60000,
doc.RootElement.GetProperty("sessionTimeoutMs").GetInt32());
}
}
Step 2: Run, verify failure
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~DataConnectionFormTests
Expected: at least one test fails — current form still has the Protocol dropdown and stores raw textarea JSON, not typed-camelCase JSON.
Step 3: Commit
git add tests/ScadaLink.CentralUI.Tests/DataConnectionFormTests.cs
git commit -m "test(ui): failing tests for DataConnectionForm refactor"
Task 11: Refactor DataConnectionForm.razor
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor(whole file)
Step 1: Rewrite the form
Replace the entire file with:
@page "/admin/connections/create"
@page "/admin/connections/{Id:int}/edit"
@page "/admin/data-connections/create"
@page "/admin/data-connections/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.DataConnections
@using ScadaLink.Commons.Types.Flattening
@using ScadaLink.Commons.Serialization
@using ScadaLink.Commons.Validators
@using ScadaLink.CentralUI.Components.Forms
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back</button>
<h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Site</label>
@if (_siteLocked)
{
<input type="text" class="form-control form-control-sm" value="@_siteName" disabled />
}
else
{
<select class="form-select form-select-sm" @bind="_formSiteId">
<option value="0">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.Id">@site.Name</option>
}
</select>
}
</div>
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<OpcUaEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryConfig"
IsLegacy="_primaryIsLegacy"
Errors="_primaryErrors" />
<h6 class="text-muted border-bottom pb-1 mt-3">Backup Endpoint</h6>
@if (!_showBackup)
{
<div class="mb-3">
<button type="button" class="btn btn-outline-secondary btn-sm"
@onclick="EnableBackup">Add Backup Endpoint</button>
</div>
}
else
{
<OpcUaEndpointEditor Title="Backup Endpoint"
IdPrefix="backup"
Config="_backupConfig"
IsLegacy="_backupIsLegacy"
Errors="_backupErrors" />
<div class="mb-2">
<label class="form-label small">Failover Retry Count</label>
<input type="number" class="form-control form-control-sm" style="max-width: 120px;"
min="1" max="20" @bind="_formFailoverRetryCount" />
<div class="form-text">Retries on active endpoint before switching to backup (default: 3)</div>
</div>
<div class="mb-3">
<button type="button" class="btn btn-outline-danger btn-sm"
@onclick="RemoveBackup">Remove Backup</button>
</div>
}
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
[SupplyParameterFromQuery] public int? SiteId { get; set; }
private bool _loading = true;
private DataConnection? _editingConnection;
private List<Site> _sites = new();
private int _formSiteId;
private string _siteName = string.Empty;
private bool _siteLocked;
private string _formName = string.Empty;
private OpcUaEndpointConfig _primaryConfig = new();
private OpcUaEndpointConfig _backupConfig = new();
private bool _primaryIsLegacy;
private bool _backupIsLegacy;
private bool _showBackup;
private int _formFailoverRetryCount = 3;
private ValidationResult? _primaryErrors;
private ValidationResult? _backupErrors;
private string? _formError;
protected override async Task OnInitializedAsync()
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
if (Id.HasValue)
{
try
{
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
if (_editingConnection != null)
{
_formSiteId = _editingConnection.SiteId;
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
_siteLocked = true;
_formName = _editingConnection.Name;
(_primaryConfig, _primaryIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration))
{
(_backupConfig, _backupIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = _editingConnection.FailoverRetryCount;
}
}
}
catch (Exception ex)
{
_formError = $"Failed to load connection: {ex.Message}";
}
}
else if (SiteId.HasValue)
{
var site = _sites.FirstOrDefault(s => s.Id == SiteId.Value);
if (site != null)
{
_formSiteId = site.Id;
_siteName = site.Name;
_siteLocked = true;
}
}
_loading = false;
}
private async Task SaveConnection()
{
_formError = null;
if (_formSiteId == 0) { _formError = "Site is required."; return; }
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
_primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
_backupErrors = _showBackup
? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
: null;
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
{
_formError = "Fix the errors below before saving.";
return;
}
var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = "OpcUa";
_editingConnection.PrimaryConfiguration = primaryJson;
_editingConnection.BackupConfiguration = backupJson;
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId)
{
PrimaryConfiguration = primaryJson,
BackupConfiguration = backupJson,
FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/connections");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void EnableBackup() => _showBackup = true;
private void RemoveBackup()
{
_showBackup = false;
_backupConfig = new OpcUaEndpointConfig();
_backupIsLegacy = false;
_formFailoverRetryCount = 3;
}
private void GoBack() => NavigationManager.NavigateTo("/admin/connections");
}
Step 2: Run the form tests, verify pass
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter FullyQualifiedName~DataConnectionFormTests
Expected: all tests pass.
Step 3: Run the full CentralUI suite
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj
Expected: all tests pass (the pre-existing DataConnectionsPageTests is unaffected; it tests the list page, not the form).
Step 4: Commit
git add src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor
git commit -m "refactor(ui/admin): DataConnectionForm uses OpcUaEndpointEditor and typed model"
Task 12: Full solution build + all test suites green
Step 1: Solution-wide build
Run: dotnet build
Expected: build succeeded, 0 errors. Warnings acceptable.
Step 2: Solution-wide test
Run: dotnet test
Expected: all test projects green. Likely-affected suites:
ScadaLink.Commons.Tests(+ new serializer & validator tests, ~19 new total)ScadaLink.DataConnectionLayer.Tests(existing tests must still pass)ScadaLink.SiteRuntime.Tests(existing tests must still pass)ScadaLink.CentralUI.Tests(+ new editor tests, + new/updated form tests)
Step 3: If anything fails, do NOT proceed to Docker. Diagnose and fix in a new commit before moving on.
There is no commit in this task — it's a checkpoint.
Task 13: Docker deploy + browser smoke
Step 1: Rebuild and start the cluster
Run: bash docker/deploy.sh
Expected: scadalink:latest rebuilt, 5 cluster containers up (central-a, central-b, site-a, site-b, site-c per docker/README.md).
Step 2: Open the connections page
URL: http://localhost:9000/admin/connections
Login: multi-role / password
Expected: existing test data shows 3 sites and ~6 OPC UA connections in the tree.
Step 3: Edit an existing legacy connection
Pick any pre-refactor connection (likely all of them on first deploy). Right-click → Edit, or use the connection row's Edit action. Expected:
- "Primary Endpoint" header renders.
- A yellow
alert-warningbanner reads "This connection was migrated from a legacy format..." - All fields are populated from the legacy JSON.
- Click Save → returns to the list page.
- Open the same connection again → the legacy banner is gone (it now uses the typed shape).
Step 4: Create a new connection with validation failure
From the list page, click a site row to select it, then click + Connection.
- Leave Endpoint URL blank, fill Name = "test", click Save.
- Expected: red text "Endpoint URL is required." under the URL field. No navigation.
Step 5: Create a new connection, valid
- Endpoint URL:
opc.tcp://plant-a:4840 - Toggle "Enable Heartbeat" → Tag path:
Sensors.Heartbeat, Max silence:30 - Add Backup Endpoint:
opc.tcp://plant-a-backup:4840 - Click Save → navigates back to the list page.
- New connection appears under the selected site.
Step 6: Verify DB JSON shape
Run: docker exec scadalink-central-a sqlite3 /var/lib/scadalink/scadalink.db "SELECT PrimaryConfiguration FROM DataConnections ORDER BY Id DESC LIMIT 1;"
Expected: typed nested JSON containing "endpointUrl", "securityMode", "heartbeat":{"tagPath":...}.
Step 7: Deploy a template that uses the connection
Optional but a strong smoke. Use any pre-existing template that has a connection binding. Navigate to /deployment/topology, deploy to the site that owns the new connection.
Expected: site logs (docker logs scadalink-site-a) show "OPC UA connected to opc.tcp://..." — the runtime parsed the new JSON shape correctly.
Step 8: If anything's off, capture screenshots / logs, fix, and re-deploy before committing.
No commit yet; this is verification.
Task 14: Final commit & push
Step 1: Review the branch state
Run: git status and git log --oneline f3b33e7..HEAD
Expected: ~7 commits from this plan (Task 1, 2, 3, 4, 5, 6, 7, 9, 10, 11 — some folded into a single commit each per the task instructions above).
Step 2: Push
Run: git push
Expected: branch advances on origin; existing PR #1 picks up the new commits.
Step 3: Verify the PR
Open https://gitea.dohertylan.com/dohertj2/scadalink-design/pulls/1 and confirm the new commits are listed.
Out of scope (do NOT do)
- No EF Core migration. Column types are unchanged.
- No CLI changes. The
data-connectionCLI commands continue to pass through raw JSON; if a user passes legacy-dict JSON, the new serializer's legacy fallback handles it. (Note for future work: the CLI could grow--endpoint-url/--security-modeflags; out of scope here.) - No new protocols. "Custom" is gone from the UI; if/when a real Custom adapter lands, the form re-introduces a protocol dropdown.
- No live (debounced) validation. Save-time only.
- No "Verify endpoint" connectivity test button.
- No requirements-doc rewrite. The design doc points at
OpcUaEndpointConfigas the canonical schema; that's enough.