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.
1646 lines
63 KiB
Markdown
1646 lines
63 KiB
Markdown
# 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](2026-05-12-opcua-config-model-design.md)
|
||
|
||
---
|
||
|
||
## Tasks at a glance
|
||
|
||
- **Task 1:** Create POCOs (`OpcUaEndpointConfig`, `OpcUaHeartbeatConfig`, `OpcUaSecurityMode`) + add `ConnectionConfig` to `ValidationCategory`.
|
||
- **Task 2:** TDD `OpcUaEndpointConfigSerializer` — write failing tests for Serialize/Deserialize/legacy/ToFlatDict/FromFlatDict.
|
||
- **Task 3:** Implement `OpcUaEndpointConfigSerializer` until tests pass.
|
||
- **Task 4:** TDD `OpcUaEndpointConfigValidator` — write failing tests for all rules.
|
||
- **Task 5:** Implement `OpcUaEndpointConfigValidator` until tests pass.
|
||
- **Task 6:** Refactor `OpcUaDataConnection.ConnectAsync` to use `FromFlatDict`. Remove `ParseInt`/`ParseBool` helpers and the string-fishing ladder.
|
||
- **Task 7:** Refactor `DeploymentManagerActor.EnsureDclConnections` to 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.razor` until tests pass.
|
||
- **Task 10:** TDD `DataConnectionForm.razor` refactor — bUnit tests for invalid-save / valid-save / legacy-load.
|
||
- **Task 11:** Refactor `DataConnectionForm.razor` to use editor; remove Protocol dropdown; set entity `Protocol = "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` (add `ConnectionConfig` enum value)
|
||
|
||
**Step 1: Create `OpcUaSecurityMode.cs`**
|
||
|
||
```csharp
|
||
namespace ScadaLink.Commons.Types.DataConnections;
|
||
|
||
public enum OpcUaSecurityMode
|
||
{
|
||
None,
|
||
Sign,
|
||
SignAndEncrypt
|
||
}
|
||
```
|
||
|
||
**Step 2: Create `OpcUaHeartbeatConfig.cs`**
|
||
|
||
```csharp
|
||
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`**
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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 a `Dictionary<string,string>` and passes it to `ConnectAsync` should keep working unchanged.
|
||
|
||
**Step 1: Replace `ConnectAsync`, `StartHeartbeatMonitorAsync`, and remove `ParseInt`/`ParseBool` helpers**
|
||
|
||
In `OpcUaDataConnection.cs`, replace lines 44-115 with:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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 sample `FlattenedConfiguration` with 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:
|
||
|
||
```csharp
|
||
/// <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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```razor
|
||
@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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```razor
|
||
@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**
|
||
|
||
```bash
|
||
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-warning` banner 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-connection` CLI 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-mode` flags; 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 `OpcUaEndpointConfig` as the canonical schema; that's enough.
|