feat(centralui+dcl): Test Bindings popup — one-shot live read of bound tags
Adds a Test Bindings button to the Connection Bindings table on the Configure
Instance page that opens a modal showing the live current value of every bound
attribute. Reuses the routing path that the OPC UA tag browser landed on:
Central: TestBindingsDialog → IBindingTester → CommunicationService
→ ReadTagValuesCommand → SiteEnvelope (Ask)
Site: SiteCommunicationActor → DeploymentManagerActor singleton
→ DataConnectionManagerActor → child DataConnectionActor
→ _adapter.ReadBatchAsync
Split mirrors the browse handler:
• Manager owns ConnectionNotFound (only it sees the per-site connection set).
• Child owns ConnectionNotConnected (pre-call status check, never stash —
read is interactive design-time), Timeout (OperationCanceledException),
ServerError (any other exception). Per-tag failures from ReadBatchAsync
become failure TagReadOutcomes without aborting the batch.
CentralUI:
• IBindingTester / BindingTester — Design-role guard via HasClaim against
JwtTokenService.RoleClaimType (not IsInRole — see c1e16cf), typed
transport-failure translation.
• TestBindingsDialog — ShowAsync(siteId, rows, instanceLabel) method-arg
pattern (no Razor parameter race; see 2c138b6), groups rows by connection
and issues one ReadAsync per connection in parallel, per-row error subline
+ per-connection banner, Refresh button re-issues the reads.
• InstanceConfigure.razor — Test Bindings button next to Save Bindings,
disabled when no testable rows. OPC UA only today (other protocols have
no ReadTagValuesCommand wiring yet).
Tests:
• Commons: ReadTagValuesCommand discovered by ManagementCommandRegistry.
• DataConnectionLayer: unknown connection → ConnectionNotFound,
not-connected adapter → ConnectionNotConnected (ReadBatchAsync NOT called),
success-path mapping (Good/Bad + per-tag error), cancellation → Timeout.
• CentralUI: register IBindingTester (and the previously-missing
IOpcUaBrowseService) on the existing InstanceConfigureAuditDrillinTests
Bunit container so the page renders cleanly with the new dialog.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@inject IBindingTester Tester
|
||||
|
||||
@if (_isVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" role="dialog" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Test Bindings — @_instanceLabel</h5>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="RefreshAsync" disabled="@_loading">
|
||||
@(_loading ? "Loading…" : "Refresh")
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="Close">Close</button>
|
||||
@if (!_loading && _rows.Count > 0)
|
||||
{
|
||||
<span class="text-muted small ms-auto">Last read: @_lastReadAt.ToLocalTime().ToString("HH:mm:ss")</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@foreach (var banner in _connectionBanners)
|
||||
{
|
||||
<div class="alert alert-warning py-2 mb-2 small">
|
||||
<strong>@banner.ConnectionName:</strong> @banner.Message
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Attribute</th>
|
||||
<th>Connection</th>
|
||||
<th>Tag Path</th>
|
||||
<th>Value</th>
|
||||
<th style="width: 90px;">Quality</th>
|
||||
<th style="width: 110px;">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-center small p-3">
|
||||
@(_loading ? "Loading…" : "No bound attributes to test.")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var row in _rows)
|
||||
{
|
||||
var outcome = LookupOutcome(row);
|
||||
<tr>
|
||||
<td class="small">@row.AttributeName</td>
|
||||
<td class="small text-muted">@row.ConnectionName</td>
|
||||
<td class="small font-monospace text-break">@row.EffectiveTagPath</td>
|
||||
<td class="small font-monospace">
|
||||
@if (_loading)
|
||||
{
|
||||
<em class="text-muted">…</em>
|
||||
}
|
||||
else if (outcome is null)
|
||||
{
|
||||
<em class="text-muted">—</em>
|
||||
}
|
||||
else if (outcome.Success)
|
||||
{
|
||||
@FormatValue(outcome.Value)
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<div class="text-danger small">error: @outcome.ErrorMessage</div>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (_loading || outcome is null)
|
||||
{
|
||||
<em class="text-muted">—</em>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge @QualityBadge(outcome.Quality)">@outcome.Quality</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
@if (_loading || outcome is null)
|
||||
{
|
||||
@("—")
|
||||
}
|
||||
else
|
||||
{
|
||||
@outcome.Timestamp.ToLocalTime().ToString("HH:mm:ss")
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="Close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// A single binding row to test — built by the page at click time from its
|
||||
/// <c>_bindings</c> table and passed in via <see cref="ShowAsync"/>. Carries
|
||||
/// the effective tag path (override ?? template default) so the dialog
|
||||
/// doesn't need page-side knowledge to compute it.
|
||||
/// </summary>
|
||||
public sealed record BindingRowToTest(
|
||||
string AttributeName,
|
||||
string ConnectionName,
|
||||
string EffectiveTagPath);
|
||||
|
||||
[Parameter] public EventCallback OnCancelled { get; set; }
|
||||
|
||||
private sealed record ConnectionBanner(string ConnectionName, string Message);
|
||||
|
||||
private bool _isVisible;
|
||||
private bool _loading;
|
||||
private string _instanceLabel = "";
|
||||
private string _runtimeSiteId = "";
|
||||
private DateTimeOffset _lastReadAt;
|
||||
private List<BindingRowToTest> _rows = new();
|
||||
private readonly List<ConnectionBanner> _connectionBanners = new();
|
||||
// Keyed by (connection, tagPath) so two connections referencing the same
|
||||
// tag path don't collide.
|
||||
private readonly Dictionary<(string Connection, string TagPath), TagReadOutcome> _outcomes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Opens the dialog and triggers an immediate one-shot read. Method-arg
|
||||
/// pattern (mirroring <c>OpcUaBrowserDialog.ShowAsync</c>) — Razor
|
||||
/// parameter binding would propagate on the next render and race the
|
||||
/// LoadAsync below.
|
||||
/// </summary>
|
||||
/// <param name="siteId">Site identifier (machine name) used by <see cref="IBindingTester"/> for routing.</param>
|
||||
/// <param name="rows">Rows to test (one per attribute with a connection + effective tag path).</param>
|
||||
/// <param name="instanceLabel">Optional label rendered in the modal header (instance unique name).</param>
|
||||
public async Task ShowAsync(
|
||||
string siteId,
|
||||
IReadOnlyList<BindingRowToTest> rows,
|
||||
string instanceLabel = "")
|
||||
{
|
||||
_runtimeSiteId = siteId;
|
||||
_instanceLabel = instanceLabel;
|
||||
_rows = rows.ToList();
|
||||
_outcomes.Clear();
|
||||
_connectionBanners.Clear();
|
||||
_isVisible = true;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_rows.Count == 0)
|
||||
{
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_outcomes.Clear();
|
||||
_connectionBanners.Clear();
|
||||
StateHasChanged();
|
||||
|
||||
// Group by connection name — one ReadTagValuesCommand per connection,
|
||||
// issued in parallel. Distinct tag paths only (a single attribute may
|
||||
// appear multiple times in the page, but reads are per (conn, tag)).
|
||||
var groups = _rows
|
||||
.GroupBy(r => r.ConnectionName, StringComparer.Ordinal)
|
||||
.Select(g => new
|
||||
{
|
||||
Connection = g.Key,
|
||||
TagPaths = g.Select(r => r.EffectiveTagPath).Distinct(StringComparer.Ordinal).ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var tasks = groups.Select(g => Tester.ReadAsync(_runtimeSiteId, g.Connection, g.TagPaths)).ToArray();
|
||||
ReadTagValuesResult[] results;
|
||||
try
|
||||
{
|
||||
results = await Task.WhenAll(tasks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Last-ditch: a service-layer exception that escaped the typed
|
||||
// failure mapping (shouldn't happen — BindingTester translates
|
||||
// everything but OperationCanceledException). Surface as a single
|
||||
// banner so the dialog stays usable.
|
||||
_connectionBanners.Add(new ConnectionBanner("(all)", $"Read failed: {ex.Message}"));
|
||||
_loading = false;
|
||||
_lastReadAt = DateTimeOffset.UtcNow;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < groups.Count; i++)
|
||||
{
|
||||
var group = groups[i];
|
||||
var result = results[i];
|
||||
|
||||
if (result.Failure is not null)
|
||||
{
|
||||
_connectionBanners.Add(new ConnectionBanner(group.Connection, FormatFailure(result.Failure)));
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var outcome in result.Outcomes)
|
||||
{
|
||||
_outcomes[(group.Connection, outcome.TagPath)] = outcome;
|
||||
}
|
||||
}
|
||||
|
||||
_lastReadAt = DateTimeOffset.UtcNow;
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private Task RefreshAsync() => LoadAsync();
|
||||
|
||||
private TagReadOutcome? LookupOutcome(BindingRowToTest row)
|
||||
=> _outcomes.GetValueOrDefault((row.ConnectionName, row.EffectiveTagPath));
|
||||
|
||||
// Maps ReadTagValuesFailureKind to a friendly banner. Raw failure.Message
|
||||
// is surfaced verbatim only for ServerError (which carries the adapter's
|
||||
// own message text).
|
||||
private static string FormatFailure(ReadTagValuesFailure failure) => failure.Kind switch
|
||||
{
|
||||
ReadTagValuesFailureKind.ConnectionNotFound => "Connection no longer exists at the site.",
|
||||
ReadTagValuesFailureKind.ConnectionNotConnected => "Connection not yet established — retry shortly.",
|
||||
ReadTagValuesFailureKind.Timeout => "Read timed out — the server may be slow. Try Refresh.",
|
||||
ReadTagValuesFailureKind.ServerError => $"Server error: {failure.Message}",
|
||||
_ => failure.Message,
|
||||
};
|
||||
|
||||
private static string FormatValue(object? value) => value switch
|
||||
{
|
||||
null => "(null)",
|
||||
string s => s,
|
||||
IFormattable f => f.ToString(null, System.Globalization.CultureInfo.InvariantCulture),
|
||||
_ => value.ToString() ?? "(null)",
|
||||
};
|
||||
|
||||
private static string QualityBadge(string quality) => quality switch
|
||||
{
|
||||
"Good" => "bg-success",
|
||||
"Uncertain" => "bg-warning text-dark",
|
||||
_ => "bg-danger",
|
||||
};
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
_isVisible = false;
|
||||
await OnCancelled.InvokeAsync();
|
||||
}
|
||||
}
|
||||
+62
-1
@@ -154,8 +154,18 @@
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-2">
|
||||
<div class="p-2 d-flex gap-2">
|
||||
<button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
|
||||
@* Test Bindings: one-shot live read of every bound attribute
|
||||
whose row has a connection picked AND an effective tag
|
||||
path. Disabled when no testable rows. Currently OPC UA
|
||||
only — other protocols (none yet) would need their own
|
||||
wire+adapter support to round-trip through ReadTagValuesCommand. *@
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
@onclick="OpenTestBindings"
|
||||
disabled="@(!HasTestableBindings())">
|
||||
Test Bindings
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -362,6 +372,11 @@
|
||||
ConnectionName="@_browserConnectionName"
|
||||
InitialNodeId="@_browserInitial"
|
||||
OnSelected="OnBrowserSelected" />
|
||||
|
||||
@* Test Bindings dialog — one-shot live read of every bound attribute.
|
||||
Method-arg ShowAsync(siteId, rows) — no Razor parameter propagation
|
||||
race (same pattern as OpcUaBrowserDialog). *@
|
||||
<TestBindingsDialog @ref="_testBindingsRef" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -399,6 +414,10 @@
|
||||
private string? _browserInitial;
|
||||
private string _siteIdentifier = "";
|
||||
|
||||
// Test Bindings dialog — single instance, args passed via ShowAsync (no
|
||||
// Razor parameter propagation race; same pattern as the OPC UA browser).
|
||||
private TestBindingsDialog? _testBindingsRef;
|
||||
|
||||
// Overrides
|
||||
private List<TemplateAttribute> _overrideAttrs = new();
|
||||
private Dictionary<string, string?> _overrideValues = new();
|
||||
@@ -583,6 +602,48 @@
|
||||
_browserAttrInEdit = null;
|
||||
}
|
||||
|
||||
// ── Test Bindings (one-shot live read of bound tags) ────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds the list of testable rows: attributes that have a connection
|
||||
/// picked AND a non-empty effective tag path AND an OPC UA connection
|
||||
/// (the only protocol routed through <c>ReadTagValuesCommand</c> today).
|
||||
/// </summary>
|
||||
private List<TestBindingsDialog.BindingRowToTest> BuildTestableRows()
|
||||
{
|
||||
var rows = new List<TestBindingsDialog.BindingRowToTest>();
|
||||
foreach (var attr in _bindingDataSourceAttrs)
|
||||
{
|
||||
var connId = GetBindingConnectionId(attr.Name);
|
||||
if (connId <= 0) continue;
|
||||
|
||||
var conn = _siteConnections.FirstOrDefault(c => c.Id == connId);
|
||||
if (conn is null) continue;
|
||||
|
||||
// OPC UA only — other protocols don't have a site-side
|
||||
// ReadTagValuesCommand handler wired up yet.
|
||||
if (!string.Equals(conn.Protocol, "OpcUa", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var effectivePath = _bindingOverrides.GetValueOrDefault(attr.Name)
|
||||
?? GetTemplateDefault(attr.Name);
|
||||
if (string.IsNullOrWhiteSpace(effectivePath)) continue;
|
||||
|
||||
rows.Add(new TestBindingsDialog.BindingRowToTest(attr.Name, conn.Name, effectivePath));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private bool HasTestableBindings() => BuildTestableRows().Count > 0;
|
||||
|
||||
private async Task OpenTestBindings()
|
||||
{
|
||||
if (_testBindingsRef is null) return;
|
||||
var rows = BuildTestableRows();
|
||||
if (rows.Count == 0) return;
|
||||
await _testBindingsRef.ShowAsync(_siteIdentifier, rows, _instance?.UniqueName ?? "");
|
||||
}
|
||||
|
||||
private async Task SaveBindings()
|
||||
{
|
||||
_saving = true;
|
||||
|
||||
@@ -55,6 +55,13 @@ public static class ServiceCollectionExtensions
|
||||
// transport failures into typed BrowseFailure results for the dialog.
|
||||
services.AddScoped<IOpcUaBrowseService, OpcUaBrowseService>();
|
||||
|
||||
// Test Bindings: facade over CommunicationService.ReadTagValuesAsync —
|
||||
// same Design-role guard + typed-failure translation as the browse
|
||||
// service. Backs the Test Bindings dialog on the Configure Instance
|
||||
// page (one-shot live read of every bound attribute, grouped by
|
||||
// connection).
|
||||
services.AddScoped<IBindingTester, BindingTester>();
|
||||
|
||||
// Roslyn-backed C# analysis for the Monaco script editor.
|
||||
// Scoped because SharedScriptCatalog wraps a scoped service.
|
||||
services.AddMemoryCache(o => o.SizeLimit = 200);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IBindingTester"/> implementation — a thin facade over
|
||||
/// <see cref="CommunicationService.ReadTagValuesAsync"/> that enforces the
|
||||
/// CentralUI-side <c>Design</c>-role trust boundary and translates transport
|
||||
/// exceptions into a typed <see cref="ReadTagValuesFailure"/> result. Mirrors
|
||||
/// <see cref="OpcUaBrowseService"/>.
|
||||
/// </summary>
|
||||
public sealed class BindingTester : IBindingTester
|
||||
{
|
||||
private readonly CommunicationService _communication;
|
||||
private readonly AuthenticationStateProvider _auth;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BindingTester"/>.
|
||||
/// </summary>
|
||||
/// <param name="communication">Central-side cluster communication service.</param>
|
||||
/// <param name="auth">Authentication state provider used for the Design-role guard.</param>
|
||||
public BindingTester(CommunicationService communication, AuthenticationStateProvider auth)
|
||||
{
|
||||
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
|
||||
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ReadTagValuesResult> ReadAsync(
|
||||
string siteId,
|
||||
string connectionName,
|
||||
IReadOnlyList<string> tagPaths,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// CentralUI-side role guard — sites don't enforce envelope-level
|
||||
// roles, so the Design check must happen here before any cross-cluster
|
||||
// traffic. Use HasClaim against JwtTokenService.RoleClaimType (not
|
||||
// IsInRole, per c1e16cf).
|
||||
var state = await _auth.GetAuthenticationStateAsync();
|
||||
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
|
||||
{
|
||||
return new ReadTagValuesResult(
|
||||
Array.Empty<TagReadOutcome>(),
|
||||
new ReadTagValuesFailure(ReadTagValuesFailureKind.ServerError, "Not authorized."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _communication.ReadTagValuesAsync(
|
||||
siteId,
|
||||
new ReadTagValuesCommand(connectionName, tagPaths),
|
||||
ct);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
// Akka Ask timed out — the site (or its OPC UA session) didn't
|
||||
// answer within CommunicationOptions.QueryTimeout. Surface as a
|
||||
// typed Timeout failure so the dialog can render an inline banner.
|
||||
return new ReadTagValuesResult(
|
||||
Array.Empty<TagReadOutcome>(),
|
||||
new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, ex.Message));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Caller-initiated cancel — propagate so Blazor can drop the
|
||||
// response cleanly. Distinct from Timeout.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Any other transport / serialization failure: keep the dialog
|
||||
// alive with a typed banner.
|
||||
return new ReadTagValuesResult(
|
||||
Array.Empty<TagReadOutcome>(),
|
||||
new ReadTagValuesFailure(ReadTagValuesFailureKind.ServerError, ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI facade over the central-to-site "Test Bindings" read command.
|
||||
/// Backs the Test Bindings dialog on the Configure Instance page: on open and
|
||||
/// on Refresh, the dialog issues one <see cref="ReadAsync"/> per distinct
|
||||
/// connection (grouped from the page's bindings table) and renders the
|
||||
/// per-tag outcomes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The service is the trust boundary for the read capability: it enforces the
|
||||
/// <c>Design</c> role at central before any cross-cluster traffic is
|
||||
/// generated, because site-side actors do not unwrap the central trust
|
||||
/// envelope. Transport failures (timeouts, unreachable sites) are translated
|
||||
/// into a typed <see cref="ReadTagValuesFailure"/> so the dialog can render an
|
||||
/// inline banner without crashing — same shape as
|
||||
/// <see cref="IOpcUaBrowseService"/>.
|
||||
/// </remarks>
|
||||
public interface IBindingTester
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the current value of one or more tags on the live server backing
|
||||
/// <paramref name="connectionName"/> at <paramref name="siteId"/>. The
|
||||
/// caller is expected to group its bindings by connection name and issue
|
||||
/// one call per group (in parallel — the dialog uses
|
||||
/// <c>Task.WhenAll</c>).
|
||||
/// </summary>
|
||||
/// <param name="siteId">The target site identifier.</param>
|
||||
/// <param name="connectionName">Name of the site-local data connection — the site's <c>DataConnectionManagerActor</c> indexes its children by name.</param>
|
||||
/// <param name="tagPaths">Tag paths to read.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<ReadTagValuesResult> ReadAsync(
|
||||
string siteId,
|
||||
string connectionName,
|
||||
IReadOnlyList<string> tagPaths,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
/// <summary>
|
||||
/// Sent from CentralUI to a specific site to read the current value of one or
|
||||
/// more tags on the live server backing a named data connection. Backs the
|
||||
/// "Test Bindings" dialog on the Configure Instance page — a one-shot read of
|
||||
/// every bound attribute, grouped by connection, with no subscription.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keyed by <see cref="ConnectionName"/> (not id) for the same reason as
|
||||
/// <see cref="BrowseOpcUaNodeCommand"/>: the site-side
|
||||
/// <c>DataConnectionManagerActor</c> indexes its children by connection name,
|
||||
/// and the central UI already has the connection name in scope from the
|
||||
/// bindings table. The central <c>DataConnections</c> table's id is not
|
||||
/// exposed at the site.
|
||||
/// </remarks>
|
||||
/// <param name="ConnectionName">Name of the site-local data connection to read against.</param>
|
||||
/// <param name="TagPaths">Tag paths to read (one batch per connection — caller groups by connection name).</param>
|
||||
public record ReadTagValuesCommand(
|
||||
string ConnectionName,
|
||||
IReadOnlyList<string> TagPaths);
|
||||
|
||||
/// <summary>
|
||||
/// Per-tag outcome of a <see cref="ReadTagValuesCommand"/>. The site returns
|
||||
/// one outcome per requested tag path; a single failing tag never aborts the
|
||||
/// batch (the underlying <c>IDataConnection.ReadBatchAsync</c> contract).
|
||||
/// </summary>
|
||||
/// <param name="TagPath">Tag path that was read — matches an entry in the request.</param>
|
||||
/// <param name="Success">True when the read returned a value; false when the per-tag read failed.</param>
|
||||
/// <param name="Value">Read value (may be null even on success); always null on failure.</param>
|
||||
/// <param name="Quality">Quality code as a string (<c>Good</c>/<c>Bad</c>/<c>Uncertain</c>); always <c>Bad</c> on failure.</param>
|
||||
/// <param name="Timestamp">Source timestamp on success; the central-noted UTC time of the failure otherwise.</param>
|
||||
/// <param name="ErrorMessage">Per-tag error message on failure; null on success.</param>
|
||||
public record TagReadOutcome(
|
||||
string TagPath,
|
||||
bool Success,
|
||||
object? Value,
|
||||
string Quality,
|
||||
DateTimeOffset Timestamp,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Reply to a <see cref="ReadTagValuesCommand"/>. Either <see cref="Outcomes"/>
|
||||
/// is populated (one entry per requested tag, in any order) and
|
||||
/// <see cref="Failure"/> is null, or <see cref="Failure"/> is set and
|
||||
/// <see cref="Outcomes"/> is empty — the latter is the connection-level
|
||||
/// short-circuit (unknown connection, not connected, server error, etc.) where
|
||||
/// no per-tag attempt was made.
|
||||
/// </summary>
|
||||
public record ReadTagValuesResult(
|
||||
IReadOnlyList<TagReadOutcome> Outcomes,
|
||||
ReadTagValuesFailure? Failure);
|
||||
|
||||
/// <summary>
|
||||
/// Connection-level failure carried by <see cref="ReadTagValuesResult"/>. The
|
||||
/// dialog maps each <see cref="ReadTagValuesFailureKind"/> to a friendly
|
||||
/// banner; <see cref="Message"/> is surfaced verbatim for the
|
||||
/// <see cref="ReadTagValuesFailureKind.ServerError"/> case.
|
||||
/// </summary>
|
||||
public record ReadTagValuesFailure(
|
||||
ReadTagValuesFailureKind Kind,
|
||||
string Message);
|
||||
|
||||
public enum ReadTagValuesFailureKind
|
||||
{
|
||||
ConnectionNotFound,
|
||||
ConnectionNotConnected,
|
||||
Timeout,
|
||||
ServerError
|
||||
}
|
||||
@@ -154,6 +154,12 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
// to its own /user/dcl-manager, which DOES have the connection.
|
||||
Receive<BrowseOpcUaNodeCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// Test Bindings (interactive design-time read) — same routing rationale
|
||||
// as BrowseOpcUaNodeCommand above: the singleton always lands on the
|
||||
// active site node, which is the node that owns the DataConnectionActor
|
||||
// children holding the live OPC UA sessions.
|
||||
Receive<ReadTagValuesCommand>(msg => _deploymentManagerProxy.Forward(msg));
|
||||
|
||||
// Pattern 7: Remote Queries
|
||||
Receive<EventLogQueryRequest>(msg =>
|
||||
{
|
||||
|
||||
@@ -370,6 +370,30 @@ public class CommunicationService
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
// ── Test Bindings (one-shot live read of bound tags) ──
|
||||
|
||||
/// <summary>
|
||||
/// Asks a site to read the current value of one or more tags on the live
|
||||
/// server backing the given data connection. Used by the CentralUI "Test
|
||||
/// Bindings" dialog on the Configure Instance page. The Ask is bounded by
|
||||
/// <see cref="CommunicationOptions.QueryTimeout"/> — same latency budget
|
||||
/// as <see cref="BrowseOpcUaNodeAsync"/> (both are interactive one-shot
|
||||
/// design-time queries).
|
||||
/// </summary>
|
||||
/// <param name="siteId">The target site identifier.</param>
|
||||
/// <param name="command">The read-tag-values command (connection name + tag paths).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The read result — per-tag outcomes plus an optional connection-level failure.</returns>
|
||||
public Task<ReadTagValuesResult> ReadTagValuesAsync(
|
||||
string siteId,
|
||||
ReadTagValuesCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new SiteEnvelope(siteId, command);
|
||||
return GetActor().Ask<ReadTagValuesResult>(
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
// ── Pattern 8: Heartbeat (site→central, Tell) ──
|
||||
// Heartbeats are received by central, not sent. No method needed here.
|
||||
|
||||
|
||||
@@ -241,6 +241,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// an inline banner.
|
||||
HandleBrowse(browse);
|
||||
break;
|
||||
case ReadTagValuesCommand read:
|
||||
// Same rule as browse — never stash; adapter is not yet
|
||||
// connected, so HandleReadTagValues short-circuits to
|
||||
// ConnectionNotConnected.
|
||||
HandleReadTagValues(read);
|
||||
break;
|
||||
case GetHealthReport:
|
||||
ReplyWithHealthReport();
|
||||
break;
|
||||
@@ -304,6 +310,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
case BrowseOpcUaNodeCommand browse:
|
||||
HandleBrowse(browse);
|
||||
break;
|
||||
case ReadTagValuesCommand read:
|
||||
HandleReadTagValues(read);
|
||||
break;
|
||||
case GetHealthReport:
|
||||
ReplyWithHealthReport();
|
||||
break;
|
||||
@@ -429,6 +438,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
// throw ConnectionNotConnectedException — mapped by HandleBrowse.
|
||||
HandleBrowse(browse);
|
||||
break;
|
||||
case ReadTagValuesCommand read:
|
||||
// Same rule as browse — never stashed; while reconnecting the
|
||||
// adapter is not Connected so HandleReadTagValues short-circuits
|
||||
// to a ConnectionNotConnected failure.
|
||||
HandleReadTagValues(read);
|
||||
break;
|
||||
case GetHealthReport:
|
||||
ReplyWithHealthReport();
|
||||
break;
|
||||
@@ -1030,6 +1045,100 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
|
||||
}).PipeTo(sender);
|
||||
}
|
||||
|
||||
// ── Test Bindings (one-shot live read of bound tags) ──
|
||||
|
||||
/// <summary>
|
||||
/// Handles a <see cref="ReadTagValuesCommand"/> forwarded by the
|
||||
/// <see cref="DataConnectionManagerActor"/>. Short-circuits to a
|
||||
/// <see cref="ReadTagValuesFailureKind.ConnectionNotConnected"/> failure
|
||||
/// when the adapter is not currently Connected (Connecting / Reconnecting
|
||||
/// states) so the dialog can render an inline banner without waiting for
|
||||
/// the adapter to fail per-tag with a generic "client is not connected"
|
||||
/// message. Otherwise calls <c>_adapter.ReadBatchAsync</c> and maps the
|
||||
/// resulting per-tag <see cref="ReadResult"/> map onto a list of
|
||||
/// <see cref="TagReadOutcome"/> (preserving every requested tag — missing
|
||||
/// adapter entries become failure outcomes).
|
||||
///
|
||||
/// Failure mapping mirrors <see cref="HandleBrowse"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ReadTagValuesFailureKind.ConnectionNotConnected"/> — adapter status is not <see cref="ConnectionHealth.Connected"/>.</item>
|
||||
/// <item><see cref="ReadTagValuesFailureKind.Timeout"/> — batch cancelled (<see cref="OperationCanceledException"/>).</item>
|
||||
/// <item><see cref="ReadTagValuesFailureKind.ServerError"/> — any other exception, message carried verbatim.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The reply is sent via <c>PipeTo(sender)</c> — same pattern as
|
||||
/// <see cref="HandleWrite"/> and <see cref="HandleBrowse"/> — so the
|
||||
/// captured <see cref="Sender"/> is safe to use from the continuation.
|
||||
/// </summary>
|
||||
private void HandleReadTagValues(ReadTagValuesCommand command)
|
||||
{
|
||||
var sender = Sender;
|
||||
|
||||
if (_adapter.Status != ConnectionHealth.Connected)
|
||||
{
|
||||
_log.Debug("[{0}] Test-bindings read requested but adapter status is {1}", _connectionName, _adapter.Status);
|
||||
sender.Tell(new ReadTagValuesResult(
|
||||
Array.Empty<TagReadOutcome>(),
|
||||
new ReadTagValuesFailure(
|
||||
ReadTagValuesFailureKind.ConnectionNotConnected,
|
||||
"Connection is not yet established.")));
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Debug("[{0}] Test-bindings read of {1} tag(s)", _connectionName, command.TagPaths.Count);
|
||||
|
||||
var tagPaths = command.TagPaths.ToList();
|
||||
_adapter.ReadBatchAsync(tagPaths).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully)
|
||||
{
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var outcomes = new List<TagReadOutcome>(tagPaths.Count);
|
||||
foreach (var tagPath in tagPaths)
|
||||
{
|
||||
if (t.Result.TryGetValue(tagPath, out var result) && result.Success && result.Value is not null)
|
||||
{
|
||||
outcomes.Add(new TagReadOutcome(
|
||||
tagPath,
|
||||
Success: true,
|
||||
Value: result.Value.Value,
|
||||
Quality: result.Value.Quality.ToString(),
|
||||
Timestamp: result.Value.Timestamp,
|
||||
ErrorMessage: null));
|
||||
}
|
||||
else
|
||||
{
|
||||
var errMsg = result?.ErrorMessage
|
||||
?? (t.Result.ContainsKey(tagPath)
|
||||
? "Read returned no value."
|
||||
: "Tag missing from adapter result.");
|
||||
outcomes.Add(new TagReadOutcome(
|
||||
tagPath,
|
||||
Success: false,
|
||||
Value: null,
|
||||
Quality: "Bad",
|
||||
Timestamp: nowUtc,
|
||||
ErrorMessage: errMsg));
|
||||
}
|
||||
}
|
||||
return new ReadTagValuesResult(outcomes, Failure: null);
|
||||
}
|
||||
|
||||
var baseEx = t.Exception?.GetBaseException();
|
||||
return baseEx switch
|
||||
{
|
||||
OperationCanceledException => new ReadTagValuesResult(
|
||||
Array.Empty<TagReadOutcome>(),
|
||||
new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, "Read cancelled.")),
|
||||
_ => new ReadTagValuesResult(
|
||||
Array.Empty<TagReadOutcome>(),
|
||||
new ReadTagValuesFailure(
|
||||
ReadTagValuesFailureKind.ServerError,
|
||||
baseEx?.Message ?? "Unknown read error.")),
|
||||
};
|
||||
}).PipeTo(sender);
|
||||
}
|
||||
|
||||
// ── Tag Resolution Retry (WP-12) ──
|
||||
|
||||
private void HandleRetryTagResolution()
|
||||
|
||||
@@ -47,6 +47,7 @@ public class DataConnectionManagerActor : ReceiveActor
|
||||
Receive<RemoveConnectionCommand>(HandleRemoveConnection);
|
||||
Receive<GetAllHealthReports>(HandleGetAllHealthReports);
|
||||
Receive<BrowseOpcUaNodeCommand>(HandleBrowse);
|
||||
Receive<ReadTagValuesCommand>(HandleReadTagValues);
|
||||
}
|
||||
|
||||
private void HandleCreateConnection(CreateConnectionCommand command)
|
||||
@@ -140,6 +141,33 @@ public class DataConnectionManagerActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a <see cref="ReadTagValuesCommand"/> from the CentralUI's Test
|
||||
/// Bindings dialog to the child <see cref="DataConnectionActor"/> that
|
||||
/// owns the named connection. Same split as <see cref="HandleBrowse"/> —
|
||||
/// the manager owns
|
||||
/// <see cref="ReadTagValuesFailureKind.ConnectionNotFound"/> because it is
|
||||
/// the only actor with site-level visibility; every other failure
|
||||
/// (not connected, server error, timeout) is resolved by the child where
|
||||
/// the adapter is held.
|
||||
/// </summary>
|
||||
private void HandleReadTagValues(ReadTagValuesCommand command)
|
||||
{
|
||||
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
|
||||
{
|
||||
actor.Forward(command);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Warning("No connection actor for {0} during test-bindings read", command.ConnectionName);
|
||||
Sender.Tell(new ReadTagValuesResult(
|
||||
Array.Empty<TagReadOutcome>(),
|
||||
new ReadTagValuesFailure(
|
||||
ReadTagValuesFailureKind.ConnectionNotFound,
|
||||
$"No data connection named '{command.ConnectionName}' at this site.")));
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRemoveConnection(RemoveConnectionCommand command)
|
||||
{
|
||||
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
|
||||
|
||||
@@ -157,6 +157,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
Receive<BrowseOpcUaNodeCommand>(msg =>
|
||||
Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
|
||||
|
||||
// Test Bindings — same singleton-only re-forward as the browse handler
|
||||
// above. Routed to this singleton (active node) by SiteCommunicationActor
|
||||
// so the dcl-manager we forward to is guaranteed to hold the live
|
||||
// DataConnectionActor children.
|
||||
Receive<ReadTagValuesCommand>(msg =>
|
||||
Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
|
||||
|
||||
// Internal startup messages
|
||||
Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
|
||||
Receive<SharedScriptsLoaded>(HandleSharedScriptsLoaded);
|
||||
|
||||
+7
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
@@ -43,6 +44,12 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
|
||||
Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For<IAuditService>()));
|
||||
Services.AddSingleton(Substitute.For<IFlatteningPipeline>());
|
||||
|
||||
// The page renders <OpcUaBrowserDialog/> and <TestBindingsDialog/> at
|
||||
// the bottom; their @inject directives need a registered service even
|
||||
// though this test doesn't open either dialog.
|
||||
Services.AddSingleton(Substitute.For<IOpcUaBrowseService>());
|
||||
Services.AddSingleton(Substitute.For<IBindingTester>());
|
||||
|
||||
// Auth: a system-wide Deployment user so SiteScope grants everything.
|
||||
var claims = new[]
|
||||
{
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="ReadTagValuesCommand"/> is discovered by
|
||||
/// <see cref="ManagementCommandRegistry"/> so it travels over the management
|
||||
/// boundary as a known command (resolvable by wire name and round-trippable
|
||||
/// through <c>GetCommandName</c> / <c>Resolve</c>). Mirrors
|
||||
/// <see cref="BrowseCommandsRegistryTests"/>.
|
||||
/// </summary>
|
||||
public class ReadTagValuesCommandRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Registry_discovers_ReadTagValuesCommand()
|
||||
{
|
||||
// GetCommandName throws ArgumentException for any type the registry
|
||||
// does not contain, so a successful call here is proof of discovery.
|
||||
var name = ManagementCommandRegistry.GetCommandName(typeof(ReadTagValuesCommand));
|
||||
|
||||
Assert.Equal("ReadTagValues", name);
|
||||
Assert.Equal(typeof(ReadTagValuesCommand), ManagementCommandRegistry.Resolve(name));
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Test Bindings (one-shot live read of bound tags): the site-side
|
||||
/// <see cref="DataConnectionManagerActor"/> + child
|
||||
/// <see cref="DataConnectionActor"/> together resolve
|
||||
/// <see cref="ReadTagValuesCommand"/> against the live adapter and surface
|
||||
/// every outcome as either a per-tag <see cref="TagReadOutcome"/> or a typed
|
||||
/// connection-level <see cref="ReadTagValuesFailure"/>. The split mirrors the
|
||||
/// browse handler: the manager owns
|
||||
/// <see cref="ReadTagValuesFailureKind.ConnectionNotFound"/> (only it knows
|
||||
/// the per-site connection set); everything else lives in the child where the
|
||||
/// adapter is held — <see cref="ReadTagValuesFailureKind.ConnectionNotConnected"/>
|
||||
/// from the pre-call status check, <see cref="ReadTagValuesFailureKind.Timeout"/>
|
||||
/// / <see cref="ReadTagValuesFailureKind.ServerError"/> from the adapter call.
|
||||
/// </summary>
|
||||
public class DataConnectionManagerReadTagValuesHandlerTests : TestKit
|
||||
{
|
||||
private readonly IDataConnectionFactory _factory;
|
||||
private readonly ISiteHealthCollector _healthCollector;
|
||||
private readonly DataConnectionOptions _options;
|
||||
|
||||
public DataConnectionManagerReadTagValuesHandlerTests()
|
||||
: base(@"akka.loglevel = WARNING")
|
||||
{
|
||||
_factory = Substitute.For<IDataConnectionFactory>();
|
||||
_healthCollector = Substitute.For<ISiteHealthCollector>();
|
||||
_options = new DataConnectionOptions
|
||||
{
|
||||
ReconnectInterval = TimeSpan.FromSeconds(30),
|
||||
TagResolutionRetryInterval = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_connection_name_returns_ConnectionNotFound()
|
||||
{
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
|
||||
|
||||
// No CreateConnectionCommand sent — the manager has zero children, so
|
||||
// any read must be rejected with ConnectionNotFound (only the manager
|
||||
// has site-level visibility into the connection set).
|
||||
manager.Tell(new ReadTagValuesCommand("unknown-connection", new[] { "ns=2;s=A" }));
|
||||
|
||||
var reply = ExpectMsg<ReadTagValuesResult>();
|
||||
Assert.NotNull(reply.Failure);
|
||||
Assert.Equal(ReadTagValuesFailureKind.ConnectionNotFound, reply.Failure!.Kind);
|
||||
Assert.Empty(reply.Outcomes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adapter_not_connected_returns_ConnectionNotConnected()
|
||||
{
|
||||
// Adapter that reports Disconnected — the child actor's pre-call
|
||||
// status check must short-circuit to ConnectionNotConnected without
|
||||
// calling ReadBatchAsync (avoids the per-tag "client is not
|
||||
// connected" noise that the OpcUa adapter would otherwise produce).
|
||||
var adapter = Substitute.For<IDataConnection>();
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException(new InvalidOperationException("simulated failure")));
|
||||
adapter.Status.Returns(ConnectionHealth.Disconnected);
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>()).Returns(adapter);
|
||||
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
|
||||
manager.Tell(new CreateConnectionCommand(
|
||||
"conn-down", "OpcUa", new Dictionary<string, string>(), null, 3));
|
||||
|
||||
// Wait for the child actor to spin up; the read handler runs in every
|
||||
// lifecycle state, so we don't need to wait for a specific Become.
|
||||
AwaitCondition(
|
||||
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
manager.Tell(new ReadTagValuesCommand("conn-down", new[] { "ns=2;s=A" }));
|
||||
|
||||
var reply = ExpectMsg<ReadTagValuesResult>(TimeSpan.FromSeconds(3));
|
||||
Assert.NotNull(reply.Failure);
|
||||
Assert.Equal(ReadTagValuesFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
|
||||
Assert.Empty(reply.Outcomes);
|
||||
|
||||
// ReadBatchAsync must NOT be called when the status guard short-circuits.
|
||||
adapter.DidNotReceive().ReadBatchAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_path_maps_results_to_TagReadOutcomes()
|
||||
{
|
||||
var adapter = Substitute.For<IDataConnection>();
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
adapter.Status.Returns(ConnectionHealth.Connected);
|
||||
|
||||
var ts = new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero);
|
||||
var batch = new Dictionary<string, ReadResult>
|
||||
{
|
||||
["ns=2;s=A"] = new ReadResult(true, new TagValue(42.7, QualityCode.Good, ts), null),
|
||||
// Adapter-reported per-tag failure (e.g. unknown node id): mapped to
|
||||
// a failure TagReadOutcome with Quality=Bad and Value=null.
|
||||
["ns=2;s=B"] = new ReadResult(false, null, "BadNodeIdUnknown"),
|
||||
};
|
||||
adapter.ReadBatchAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(batch);
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>()).Returns(adapter);
|
||||
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
|
||||
manager.Tell(new CreateConnectionCommand(
|
||||
"conn-ok", "OpcUa", new Dictionary<string, string>(), null, 3));
|
||||
|
||||
AwaitCondition(
|
||||
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
manager.Tell(new ReadTagValuesCommand("conn-ok", new[] { "ns=2;s=A", "ns=2;s=B" }));
|
||||
|
||||
var reply = ExpectMsg<ReadTagValuesResult>(TimeSpan.FromSeconds(3));
|
||||
Assert.Null(reply.Failure);
|
||||
Assert.Equal(2, reply.Outcomes.Count);
|
||||
|
||||
var aOutcome = reply.Outcomes.Single(o => o.TagPath == "ns=2;s=A");
|
||||
Assert.True(aOutcome.Success);
|
||||
Assert.Equal(42.7, aOutcome.Value);
|
||||
Assert.Equal("Good", aOutcome.Quality);
|
||||
Assert.Equal(ts, aOutcome.Timestamp);
|
||||
Assert.Null(aOutcome.ErrorMessage);
|
||||
|
||||
var bOutcome = reply.Outcomes.Single(o => o.TagPath == "ns=2;s=B");
|
||||
Assert.False(bOutcome.Success);
|
||||
Assert.Null(bOutcome.Value);
|
||||
Assert.Equal("Bad", bOutcome.Quality);
|
||||
Assert.Equal("BadNodeIdUnknown", bOutcome.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adapter_OperationCancelled_returns_Timeout()
|
||||
{
|
||||
var adapter = Substitute.For<IDataConnection>();
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
adapter.Status.Returns(ConnectionHealth.Connected);
|
||||
adapter.ReadBatchAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<IReadOnlyDictionary<string, ReadResult>>(
|
||||
new OperationCanceledException("test cancel")));
|
||||
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>()).Returns(adapter);
|
||||
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_factory, _options, _healthCollector, null)));
|
||||
manager.Tell(new CreateConnectionCommand(
|
||||
"conn-slow", "OpcUa", new Dictionary<string, string>(), null, 3));
|
||||
|
||||
AwaitCondition(
|
||||
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
manager.Tell(new ReadTagValuesCommand("conn-slow", new[] { "ns=2;s=A" }));
|
||||
|
||||
var reply = ExpectMsg<ReadTagValuesResult>(TimeSpan.FromSeconds(3));
|
||||
Assert.NotNull(reply.Failure);
|
||||
Assert.Equal(ReadTagValuesFailureKind.Timeout, reply.Failure!.Kind);
|
||||
Assert.Empty(reply.Outcomes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user