Compare commits
4 Commits
phase-7-st
...
phase-7-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0687bb2e2d | ||
| 4d4f08af0d | |||
|
|
f1f53e1789 | ||
| e97db2d108 |
@@ -0,0 +1,79 @@
|
|||||||
|
@page "/alarms/historian"
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
|
||||||
|
@inject HistorianDiagnosticsService Diag
|
||||||
|
|
||||||
|
<h1>Alarm historian</h1>
|
||||||
|
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<small class="text-muted">Drain state</small>
|
||||||
|
<h4><span class="badge @BadgeFor(_status.DrainState)">@_status.DrainState</span></h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<small class="text-muted">Queue depth</small>
|
||||||
|
<h4>@_status.QueueDepth.ToString("N0")</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<small class="text-muted">Dead-letter depth</small>
|
||||||
|
<h4 class="@(_status.DeadLetterDepth > 0 ? "text-warning" : "")">@_status.DeadLetterDepth.ToString("N0")</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<small class="text-muted">Last success</small>
|
||||||
|
<h4>@(_status.LastSuccessUtc?.ToString("u") ?? "—")</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_status.LastError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning mt-3 mb-0">
|
||||||
|
<strong>Last error:</strong> @_status.LastError
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
|
||||||
|
<button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
|
||||||
|
Retry dead-lettered (@_status.DeadLetterDepth)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_retryResult is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success mt-3">Requeued @_retryResult row(s) for retry.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled);
|
||||||
|
private int? _retryResult;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => _status = Diag.GetStatus();
|
||||||
|
|
||||||
|
private Task RefreshAsync()
|
||||||
|
{
|
||||||
|
_status = Diag.GetStatus();
|
||||||
|
_retryResult = null;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RetryDeadLetteredAsync()
|
||||||
|
{
|
||||||
|
_retryResult = Diag.TryRetryDeadLettered();
|
||||||
|
_status = Diag.GetStatus();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BadgeFor(HistorianDrainState s) => s switch
|
||||||
|
{
|
||||||
|
HistorianDrainState.Idle => "bg-success",
|
||||||
|
HistorianDrainState.Draining => "bg-info",
|
||||||
|
HistorianDrainState.BackingOff => "bg-warning text-dark",
|
||||||
|
HistorianDrainState.Disabled => "bg-secondary",
|
||||||
|
_ => "bg-secondary",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||||
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||||
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
|
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card sticky-top">
|
<div class="card sticky-top">
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
@*
|
||||||
|
Monaco-backed C# code editor (Phase 7 Stream F). Progressive enhancement:
|
||||||
|
textarea renders immediately, Monaco mounts via JS interop after first render.
|
||||||
|
Monaco script tags are loaded once from the parent layout (wwwroot/js/monaco-loader.js
|
||||||
|
pulls the CDN bundle).
|
||||||
|
|
||||||
|
Stream F keeps the interop surface small — bind `Source` two-way, and the parent
|
||||||
|
tab re-renders on change for the dependency preview. The test-harness button
|
||||||
|
lives in the parent so one editor can drive multiple script types.
|
||||||
|
*@
|
||||||
|
|
||||||
|
<div class="script-editor">
|
||||||
|
<textarea class="form-control font-monospace" rows="14" spellcheck="false"
|
||||||
|
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Source { get; set; } = string.Empty;
|
||||||
|
[Parameter] public EventCallback<string> SourceChanged { get; set; }
|
||||||
|
|
||||||
|
private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId);
|
||||||
|
}
|
||||||
|
catch (JSException)
|
||||||
|
{
|
||||||
|
// Monaco bundle not yet loaded on this page — textarea fallback is
|
||||||
|
// still functional.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.Scripting
|
||||||
|
@inject ScriptService ScriptSvc
|
||||||
|
@inject ScriptTestHarnessService Harness
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0">Scripts</h4>
|
||||||
|
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/monaco-loader.js"></script>
|
||||||
|
|
||||||
|
@if (_loading) { <p class="text-muted">Loading…</p> }
|
||||||
|
else if (_scripts.Count == 0 && _editing is null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">No scripts yet in this draft.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="list-group">
|
||||||
|
@foreach (var s in _scripts)
|
||||||
|
{
|
||||||
|
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
|
||||||
|
@onclick="() => Open(s)">
|
||||||
|
<strong>@s.Name</strong>
|
||||||
|
<div class="small text-muted font-monospace">@s.ScriptId</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
@if (_editing is not null)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
|
||||||
|
<div>
|
||||||
|
@if (!_isNew)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger me-2" @onclick="DeleteAsync">Delete</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input class="form-control" @bind="_editing.Name"/>
|
||||||
|
</div>
|
||||||
|
<label class="form-label">Source</label>
|
||||||
|
<ScriptEditor @bind-Source="_editing.SourceCode"/>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="PreviewDependencies">Analyze dependencies</button>
|
||||||
|
<button class="btn btn-sm btn-outline-info ms-2" @onclick="RunHarnessAsync" disabled="@_harnessBusy">Run test harness</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_dependencies is not null)
|
||||||
|
{
|
||||||
|
<div class="mt-3">
|
||||||
|
<strong>Inferred reads</strong>
|
||||||
|
@if (_dependencies.Reads.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="mb-1">
|
||||||
|
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> }
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
<strong>Inferred writes</strong>
|
||||||
|
@if (_dependencies.Writes.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="mb-1">
|
||||||
|
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> }
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
@if (_dependencies.Rejections.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger mt-2">
|
||||||
|
<strong>Non-literal paths rejected:</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> }
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_testResult is not null)
|
||||||
|
{
|
||||||
|
<div class="mt-3 border-top pt-3">
|
||||||
|
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span>
|
||||||
|
@if (_testResult.Outcome == ScriptTestOutcome.Success)
|
||||||
|
{
|
||||||
|
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div>
|
||||||
|
@if (_testResult.Writes.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="mt-1"><strong>Writes:</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> }
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (_testResult.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning mt-2 mb-0">
|
||||||
|
@foreach (var e in _testResult.Errors) { <div>@e</div> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (_testResult.LogEvents.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="mt-2"><strong>Script log output:</strong>
|
||||||
|
<ul class="small mb-0">
|
||||||
|
@foreach (var e in _testResult.LogEvents) { <li>[@e.Level] @e.RenderMessage()</li> }
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _busy;
|
||||||
|
private bool _harnessBusy;
|
||||||
|
private bool _isNew;
|
||||||
|
private List<Script> _scripts = [];
|
||||||
|
private Script? _editing;
|
||||||
|
private DependencyExtractionResult? _dependencies;
|
||||||
|
private ScriptTestResult? _testResult;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Open(Script s)
|
||||||
|
{
|
||||||
|
_editing = new Script
|
||||||
|
{
|
||||||
|
ScriptRowId = s.ScriptRowId, GenerationId = s.GenerationId,
|
||||||
|
ScriptId = s.ScriptId, Name = s.Name, SourceCode = s.SourceCode,
|
||||||
|
SourceHash = s.SourceHash, Language = s.Language,
|
||||||
|
};
|
||||||
|
_isNew = false;
|
||||||
|
_dependencies = null;
|
||||||
|
_testResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartNew()
|
||||||
|
{
|
||||||
|
_editing = new Script
|
||||||
|
{
|
||||||
|
GenerationId = GenerationId, ScriptId = "",
|
||||||
|
Name = "new-script", SourceCode = "return 0;", SourceHash = "",
|
||||||
|
};
|
||||||
|
_isNew = true;
|
||||||
|
_dependencies = null;
|
||||||
|
_testResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (_editing is null) return;
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_isNew)
|
||||||
|
await ScriptSvc.AddAsync(GenerationId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||||
|
else
|
||||||
|
await ScriptSvc.UpdateAsync(GenerationId, _editing.ScriptId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||||
|
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
_isNew = false;
|
||||||
|
}
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync()
|
||||||
|
{
|
||||||
|
if (_editing is null || _isNew) return;
|
||||||
|
await ScriptSvc.DeleteAsync(GenerationId, _editing.ScriptId, CancellationToken.None);
|
||||||
|
_editing = null;
|
||||||
|
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PreviewDependencies()
|
||||||
|
{
|
||||||
|
if (_editing is null) return;
|
||||||
|
_dependencies = DependencyExtractor.Extract(_editing.SourceCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunHarnessAsync()
|
||||||
|
{
|
||||||
|
if (_editing is null) return;
|
||||||
|
_harnessBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_dependencies ??= DependencyExtractor.Extract(_editing.SourceCode);
|
||||||
|
var inputs = new Dictionary<string, DataValueSnapshot>();
|
||||||
|
foreach (var read in _dependencies.Reads)
|
||||||
|
inputs[read] = new DataValueSnapshot(0.0, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||||
|
_testResult = await Harness.RunVirtualTagAsync(_editing.SourceCode, inputs, CancellationToken.None);
|
||||||
|
}
|
||||||
|
finally { _harnessBusy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,18 @@ builder.Services.AddScoped<EquipmentImportBatchService>();
|
|||||||
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
||||||
|
|
||||||
|
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
|
||||||
|
// harness, and historian diagnostics. The historian sink is the Null variant here —
|
||||||
|
// the real SqliteStoreAndForwardSink lives in the server process. Admin reads status
|
||||||
|
// from whichever sink is provided at composition time.
|
||||||
|
builder.Services.AddScoped<ScriptService>();
|
||||||
|
builder.Services.AddScoped<VirtualTagService>();
|
||||||
|
builder.Services.AddScoped<ScriptedAlarmService>();
|
||||||
|
builder.Services.AddScoped<ScriptTestHarnessService>();
|
||||||
|
builder.Services.AddScoped<HistorianDiagnosticsService>();
|
||||||
|
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.IAlarmHistorianSink>(
|
||||||
|
ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.NullAlarmHistorianSink.Instance);
|
||||||
|
|
||||||
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||||
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||||
// filesystem operations.
|
// filesystem operations.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Surfaces the local-node historian queue health on the Admin UI's
|
||||||
|
/// <c>/alarms/historian</c> diagnostics page (Phase 7 plan decisions #16/#21).
|
||||||
|
/// Exposes queue depth / drain state / last-error, and lets the operator retry
|
||||||
|
/// dead-lettered rows without restarting the node.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The sink injected here is the server-process <see cref="IAlarmHistorianSink"/>.
|
||||||
|
/// When <see cref="NullAlarmHistorianSink"/> is bound (historian disabled for this
|
||||||
|
/// deployment), <see cref="TryRetryDeadLettered"/> silently returns 0 and
|
||||||
|
/// <see cref="GetStatus"/> reports <see cref="HistorianDrainState.Disabled"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HistorianDiagnosticsService(IAlarmHistorianSink sink)
|
||||||
|
{
|
||||||
|
public HistorianSinkStatus GetStatus() => sink.GetStatus();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operator action from the UI's "Retry dead-lettered" button. Returns the number
|
||||||
|
/// of rows revived so the UI can flash a confirmation. When the live sink doesn't
|
||||||
|
/// implement retry (test doubles, Null sink), returns 0.
|
||||||
|
/// </summary>
|
||||||
|
public int TryRetryDeadLettered()
|
||||||
|
{
|
||||||
|
if (sink is SqliteStoreAndForwardSink concrete)
|
||||||
|
return concrete.RetryDeadLettered();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
66
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draft-generation CRUD for <see cref="Script"/> rows — the C# source code referenced
|
||||||
|
/// by Phase 7 virtual tags and scripted alarms. <see cref="Script.SourceHash"/> is
|
||||||
|
/// recomputed on every save so Core.Scripting's compile cache sees a fresh key when
|
||||||
|
/// source changes and reuses the compile when it doesn't.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScriptService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<Script>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.Scripts.AsNoTracking()
|
||||||
|
.Where(s => s.GenerationId == generationId)
|
||||||
|
.OrderBy(s => s.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public Task<Script?> GetAsync(long generationId, string scriptId, CancellationToken ct) =>
|
||||||
|
db.Scripts.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.GenerationId == generationId && s.ScriptId == scriptId, ct);
|
||||||
|
|
||||||
|
public async Task<Script> AddAsync(long generationId, string name, string sourceCode, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = new Script
|
||||||
|
{
|
||||||
|
GenerationId = generationId,
|
||||||
|
ScriptId = $"scr-{Guid.NewGuid():N}"[..20],
|
||||||
|
Name = name,
|
||||||
|
SourceCode = sourceCode,
|
||||||
|
SourceHash = ComputeHash(sourceCode),
|
||||||
|
};
|
||||||
|
db.Scripts.Add(s);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Script> UpdateAsync(long generationId, string scriptId, string name, string sourceCode, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Script '{scriptId}' not found in generation {generationId}");
|
||||||
|
s.Name = name;
|
||||||
|
s.SourceCode = sourceCode;
|
||||||
|
s.SourceHash = ComputeHash(sourceCode);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(long generationId, string scriptId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct);
|
||||||
|
if (s is null) return;
|
||||||
|
db.Scripts.Remove(s);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ComputeHash(string source)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(source ?? string.Empty));
|
||||||
|
return Convert.ToHexString(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs
Normal file
121
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using Serilog; // resolves Serilog.ILogger explicitly in signatures
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dry-run harness for the Phase 7 scripting UI. Takes a script + a synthetic input
|
||||||
|
/// map + evaluates once, returns the output (or rejection / exception) plus any
|
||||||
|
/// logger emissions the script produced. Per Phase 7 plan decision #22: only inputs
|
||||||
|
/// the <see cref="DependencyExtractor"/> identified can be supplied, so a dependency
|
||||||
|
/// the harness can't prove statically surfaces as a harness error, not a runtime
|
||||||
|
/// surprise later.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScriptTestHarnessService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluate <paramref name="source"/> as a virtual-tag script (return value is the
|
||||||
|
/// tag's new value). <paramref name="inputs"/> supplies synthetic
|
||||||
|
/// <see cref="DataValueSnapshot"/>s for every path the extractor found.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ScriptTestResult> RunVirtualTagAsync(
|
||||||
|
string source, IDictionary<string, DataValueSnapshot> inputs, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var deps = DependencyExtractor.Extract(source);
|
||||||
|
if (!deps.IsValid)
|
||||||
|
return ScriptTestResult.DependencyRejections(deps.Rejections);
|
||||||
|
|
||||||
|
var missing = deps.Reads.Where(r => !inputs.ContainsKey(r)).ToArray();
|
||||||
|
if (missing.Length > 0)
|
||||||
|
return ScriptTestResult.MissingInputs(missing);
|
||||||
|
|
||||||
|
var extra = inputs.Keys.Where(k => !deps.Reads.Contains(k)).ToArray();
|
||||||
|
if (extra.Length > 0)
|
||||||
|
return ScriptTestResult.UnknownInputs(extra);
|
||||||
|
|
||||||
|
ScriptEvaluator<HarnessVirtualTagContext, object?> evaluator;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evaluator = ScriptEvaluator<HarnessVirtualTagContext, object?>.Compile(source);
|
||||||
|
}
|
||||||
|
catch (Exception compileEx)
|
||||||
|
{
|
||||||
|
return ScriptTestResult.Threw(compileEx.Message, []);
|
||||||
|
}
|
||||||
|
var capturing = new CapturingSink();
|
||||||
|
var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(capturing).CreateLogger();
|
||||||
|
var ctx = new HarnessVirtualTagContext(inputs, logger);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await evaluator.RunAsync(ctx, ct);
|
||||||
|
return ScriptTestResult.Ok(result, ctx.Writes, capturing.Events);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ScriptTestResult.Threw(ex.Message, capturing.Events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public so Roslyn's script compilation can reference the context type through the
|
||||||
|
// ScriptGlobals<T> surface. The harness instantiates this directly; operators never see it.
|
||||||
|
public sealed class HarnessVirtualTagContext(
|
||||||
|
IDictionary<string, DataValueSnapshot> inputs, Serilog.ILogger logger) : ScriptContext
|
||||||
|
{
|
||||||
|
public Dictionary<string, object?> Writes { get; } = [];
|
||||||
|
public override DataValueSnapshot GetTag(string path) =>
|
||||||
|
inputs.TryGetValue(path, out var v)
|
||||||
|
? v
|
||||||
|
: new DataValueSnapshot(null, Ua.StatusCodes.BadNotFound, null, DateTime.UtcNow);
|
||||||
|
public override void SetVirtualTag(string path, object? value) => Writes[path] = value;
|
||||||
|
public override DateTime Now => DateTime.UtcNow;
|
||||||
|
public override Serilog.ILogger Logger => logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingSink : ILogEventSink
|
||||||
|
{
|
||||||
|
public List<LogEvent> Events { get; } = [];
|
||||||
|
public void Emit(LogEvent e) => Events.Add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Harness outcome: outputs, write-set, logger events, or a rejection/throw reason.</summary>
|
||||||
|
public sealed record ScriptTestResult(
|
||||||
|
ScriptTestOutcome Outcome,
|
||||||
|
object? Output,
|
||||||
|
IReadOnlyDictionary<string, object?> Writes,
|
||||||
|
IReadOnlyList<LogEvent> LogEvents,
|
||||||
|
IReadOnlyList<string> Errors)
|
||||||
|
{
|
||||||
|
public static ScriptTestResult Ok(object? output, IReadOnlyDictionary<string, object?> writes, IReadOnlyList<LogEvent> logs) =>
|
||||||
|
new(ScriptTestOutcome.Success, output, writes, logs, []);
|
||||||
|
public static ScriptTestResult Threw(string reason, IReadOnlyList<LogEvent> logs) =>
|
||||||
|
new(ScriptTestOutcome.Threw, null, new Dictionary<string, object?>(), logs, [reason]);
|
||||||
|
public static ScriptTestResult DependencyRejections(IReadOnlyList<DependencyRejection> rejs) =>
|
||||||
|
new(ScriptTestOutcome.DependencyRejected, null, new Dictionary<string, object?>(), [],
|
||||||
|
rejs.Select(r => r.Message).ToArray());
|
||||||
|
public static ScriptTestResult MissingInputs(string[] paths) =>
|
||||||
|
new(ScriptTestOutcome.MissingInputs, null, new Dictionary<string, object?>(), [],
|
||||||
|
paths.Select(p => $"Missing synthetic input: {p}").ToArray());
|
||||||
|
public static ScriptTestResult UnknownInputs(string[] paths) =>
|
||||||
|
new(ScriptTestOutcome.UnknownInputs, null, new Dictionary<string, object?>(), [],
|
||||||
|
paths.Select(p => $"Input '{p}' is not referenced by the script — remove it").ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ScriptTestOutcome
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
Threw,
|
||||||
|
DependencyRejected,
|
||||||
|
MissingInputs,
|
||||||
|
UnknownInputs,
|
||||||
|
}
|
||||||
|
|
||||||
|
file static class Ua
|
||||||
|
{
|
||||||
|
// Mirrors OPC UA StatusCodes.BadNotFound without pulling the OPC stack into Admin.
|
||||||
|
public static class StatusCodes { public const uint BadNotFound = 0x803E0000; }
|
||||||
|
}
|
||||||
55
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs
Normal file
55
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>Draft-generation CRUD for <see cref="ScriptedAlarm"/> rows.</summary>
|
||||||
|
public sealed class ScriptedAlarmService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<ScriptedAlarm>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.ScriptedAlarms.AsNoTracking()
|
||||||
|
.Where(a => a.GenerationId == generationId)
|
||||||
|
.OrderBy(a => a.EquipmentId).ThenBy(a => a.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<ScriptedAlarm> AddAsync(
|
||||||
|
long generationId, string equipmentId, string name, string alarmType,
|
||||||
|
int severity, string messageTemplate, string predicateScriptId,
|
||||||
|
bool historizeToAveva, bool retain, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var a = new ScriptedAlarm
|
||||||
|
{
|
||||||
|
GenerationId = generationId,
|
||||||
|
ScriptedAlarmId = $"sal-{Guid.NewGuid():N}"[..20],
|
||||||
|
EquipmentId = equipmentId,
|
||||||
|
Name = name,
|
||||||
|
AlarmType = alarmType,
|
||||||
|
Severity = severity,
|
||||||
|
MessageTemplate = messageTemplate,
|
||||||
|
PredicateScriptId = predicateScriptId,
|
||||||
|
HistorizeToAveva = historizeToAveva,
|
||||||
|
Retain = retain,
|
||||||
|
};
|
||||||
|
db.ScriptedAlarms.Add(a);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(long generationId, string scriptedAlarmId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var a = await db.ScriptedAlarms.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||||
|
if (a is null) return;
|
||||||
|
db.ScriptedAlarms.Remove(a);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the persistent state row (ack/confirm/shelve) for this alarm identity —
|
||||||
|
/// alarm state is NOT generation-scoped per Phase 7 plan decision #14, so the
|
||||||
|
/// lookup is by <see cref="ScriptedAlarm.ScriptedAlarmId"/> only.
|
||||||
|
/// </summary>
|
||||||
|
public Task<ScriptedAlarmState?> GetStateAsync(string scriptedAlarmId, CancellationToken ct) =>
|
||||||
|
db.ScriptedAlarmStates.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||||
|
}
|
||||||
53
src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs
Normal file
53
src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>Draft-generation CRUD for <see cref="VirtualTag"/> rows.</summary>
|
||||||
|
public sealed class VirtualTagService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<VirtualTag>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.VirtualTags.AsNoTracking()
|
||||||
|
.Where(v => v.GenerationId == generationId)
|
||||||
|
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<VirtualTag> AddAsync(
|
||||||
|
long generationId, string equipmentId, string name, string dataType, string scriptId,
|
||||||
|
bool changeTriggered, int? timerIntervalMs, bool historize, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var v = new VirtualTag
|
||||||
|
{
|
||||||
|
GenerationId = generationId,
|
||||||
|
VirtualTagId = $"vt-{Guid.NewGuid():N}"[..20],
|
||||||
|
EquipmentId = equipmentId,
|
||||||
|
Name = name,
|
||||||
|
DataType = dataType,
|
||||||
|
ScriptId = scriptId,
|
||||||
|
ChangeTriggered = changeTriggered,
|
||||||
|
TimerIntervalMs = timerIntervalMs,
|
||||||
|
Historize = historize,
|
||||||
|
};
|
||||||
|
db.VirtualTags.Add(v);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(long generationId, string virtualTagId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct);
|
||||||
|
if (v is null) return;
|
||||||
|
db.VirtualTags.Remove(v);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VirtualTag> UpdateEnabledAsync(long generationId, string virtualTagId, bool enabled, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct)
|
||||||
|
?? throw new InvalidOperationException($"VirtualTag '{virtualTagId}' not found in generation {generationId}");
|
||||||
|
v.Enabled = enabled;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
59
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js
Normal file
59
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Phase 7 Stream F — Monaco editor loader for ScriptEditor.razor.
|
||||||
|
// Progressive enhancement: the textarea is authoritative until Monaco attaches;
|
||||||
|
// after attach, Monaco syncs back into the textarea on every change so Blazor's
|
||||||
|
// @bind still sees the latest value.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
if (window.otOpcUaScriptEditor) return;
|
||||||
|
|
||||||
|
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
|
||||||
|
let loaderPromise = null;
|
||||||
|
|
||||||
|
function ensureLoader() {
|
||||||
|
if (loaderPromise) return loaderPromise;
|
||||||
|
loaderPromise = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = `${MONACO_CDN}/loader.js`;
|
||||||
|
script.onload = () => {
|
||||||
|
window.require.config({ paths: { vs: MONACO_CDN } });
|
||||||
|
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
|
||||||
|
};
|
||||||
|
script.onerror = () => reject(new Error('Monaco CDN unreachable'));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
return loaderPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.otOpcUaScriptEditor = {
|
||||||
|
attach: async function (textareaId) {
|
||||||
|
const ta = document.getElementById(textareaId);
|
||||||
|
if (!ta) return;
|
||||||
|
const monaco = await ensureLoader();
|
||||||
|
|
||||||
|
// Mount Monaco over the textarea. The textarea stays in the DOM as the
|
||||||
|
// source of truth for Blazor's @bind — Monaco mirrors into it on every
|
||||||
|
// keystroke so server-side state stays in sync.
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.style.height = '340px';
|
||||||
|
host.style.border = '1px solid #ced4da';
|
||||||
|
host.style.borderRadius = '0.25rem';
|
||||||
|
ta.style.display = 'none';
|
||||||
|
ta.parentNode.insertBefore(host, ta);
|
||||||
|
|
||||||
|
const editor = monaco.editor.create(host, {
|
||||||
|
value: ta.value,
|
||||||
|
language: 'csharp',
|
||||||
|
theme: 'vs',
|
||||||
|
automaticLayout: true,
|
||||||
|
fontSize: 13,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent(() => {
|
||||||
|
ta.value = editor.getValue();
|
||||||
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -33,6 +33,18 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// (holding registers with level-set values, set-point writes to analog tags) — the
|
/// (holding registers with level-set values, set-point writes to analog tags) — the
|
||||||
/// capability invoker respects this flag when deciding whether to apply Polly retry.
|
/// capability invoker respects this flag when deciding whether to apply Polly retry.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="Source">
|
||||||
|
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
|
||||||
|
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="VirtualTagId">
|
||||||
|
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
|
||||||
|
/// logical id the VirtualTagEngine addresses by. Null otherwise.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ScriptedAlarmId">
|
||||||
|
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
||||||
|
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
||||||
|
/// </param>
|
||||||
public sealed record DriverAttributeInfo(
|
public sealed record DriverAttributeInfo(
|
||||||
string FullName,
|
string FullName,
|
||||||
DriverDataType DriverDataType,
|
DriverDataType DriverDataType,
|
||||||
@@ -41,4 +53,21 @@ public sealed record DriverAttributeInfo(
|
|||||||
SecurityClassification SecurityClass,
|
SecurityClassification SecurityClass,
|
||||||
bool IsHistorized,
|
bool IsHistorized,
|
||||||
bool IsAlarm = false,
|
bool IsAlarm = false,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
NodeSourceKind Source = NodeSourceKind.Driver,
|
||||||
|
string? VirtualTagId = null,
|
||||||
|
string? ScriptedAlarmId = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
||||||
|
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
|
||||||
|
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
|
||||||
|
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
|
||||||
|
/// materialized by the ScriptedAlarmEngine.
|
||||||
|
/// </summary>
|
||||||
|
public enum NodeSourceKind
|
||||||
|
{
|
||||||
|
Driver = 0,
|
||||||
|
Virtual = 1,
|
||||||
|
ScriptedAlarm = 2,
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,16 @@ public static class EquipmentNodeWalker
|
|||||||
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
|
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var virtualTagsByEquipment = (content.VirtualTags ?? [])
|
||||||
|
.Where(v => v.Enabled)
|
||||||
|
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
|
||||||
|
.Where(a => a.Enabled)
|
||||||
|
.GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
|
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
|
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
|
||||||
@@ -103,9 +113,17 @@ public static class EquipmentNodeWalker
|
|||||||
AddIdentifierProperties(equipmentBuilder, equipment);
|
AddIdentifierProperties(equipmentBuilder, equipment);
|
||||||
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
|
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
|
||||||
|
|
||||||
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
|
if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
|
||||||
foreach (var tag in equipmentTags)
|
foreach (var tag in equipmentTags)
|
||||||
AddTagVariable(equipmentBuilder, tag);
|
AddTagVariable(equipmentBuilder, tag);
|
||||||
|
|
||||||
|
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
|
||||||
|
foreach (var vtag in vTags)
|
||||||
|
AddVirtualTagVariable(equipmentBuilder, vtag);
|
||||||
|
|
||||||
|
if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
|
||||||
|
foreach (var alarm in alarms)
|
||||||
|
AddScriptedAlarmVariable(equipmentBuilder, alarm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +175,55 @@ public static class EquipmentNodeWalker
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static DriverDataType ParseDriverDataType(string raw) =>
|
private static DriverDataType ParseDriverDataType(string raw) =>
|
||||||
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
|
||||||
|
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
|
||||||
|
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
|
||||||
|
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
|
||||||
|
/// driver.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
|
||||||
|
{
|
||||||
|
var attr = new DriverAttributeInfo(
|
||||||
|
FullName: vtag.VirtualTagId,
|
||||||
|
DriverDataType: ParseDriverDataType(vtag.DataType),
|
||||||
|
IsArray: false,
|
||||||
|
ArrayDim: null,
|
||||||
|
SecurityClass: SecurityClassification.FreeAccess,
|
||||||
|
IsHistorized: vtag.Historize,
|
||||||
|
IsAlarm: false,
|
||||||
|
WriteIdempotent: false,
|
||||||
|
Source: NodeSourceKind.Virtual,
|
||||||
|
VirtualTagId: vtag.VirtualTagId,
|
||||||
|
ScriptedAlarmId: null);
|
||||||
|
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
|
||||||
|
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
|
||||||
|
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
|
||||||
|
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
|
||||||
|
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
|
||||||
|
/// materialization path.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
|
||||||
|
{
|
||||||
|
var attr = new DriverAttributeInfo(
|
||||||
|
FullName: alarm.ScriptedAlarmId,
|
||||||
|
DriverDataType: DriverDataType.Boolean,
|
||||||
|
IsArray: false,
|
||||||
|
ArrayDim: null,
|
||||||
|
SecurityClass: SecurityClassification.FreeAccess,
|
||||||
|
IsHistorized: false,
|
||||||
|
IsAlarm: true,
|
||||||
|
WriteIdempotent: false,
|
||||||
|
Source: NodeSourceKind.ScriptedAlarm,
|
||||||
|
VirtualTagId: null,
|
||||||
|
ScriptedAlarmId: alarm.ScriptedAlarmId);
|
||||||
|
equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -170,4 +237,6 @@ public sealed record EquipmentNamespaceContent(
|
|||||||
IReadOnlyList<UnsArea> Areas,
|
IReadOnlyList<UnsArea> Areas,
|
||||||
IReadOnlyList<UnsLine> Lines,
|
IReadOnlyList<UnsLine> Lines,
|
||||||
IReadOnlyList<Equipment> Equipment,
|
IReadOnlyList<Equipment> Equipment,
|
||||||
IReadOnlyList<Tag> Tags);
|
IReadOnlyList<Tag> Tags,
|
||||||
|
IReadOnlyList<VirtualTag>? VirtualTags = null,
|
||||||
|
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);
|
||||||
|
|||||||
196
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/Phase7ServicesTests.cs
Normal file
196
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/Phase7ServicesTests.cs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin-side services shipped in Phase 7 Stream F — draft CRUD for scripts + virtual
|
||||||
|
/// tags + scripted alarms, the pre-publish test harness, and the historian
|
||||||
|
/// diagnostics façade.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class Phase7ServicesTests
|
||||||
|
{
|
||||||
|
private static OtOpcUaConfigDbContext NewDb([System.Runtime.CompilerServices.CallerMemberName] string test = "")
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||||
|
.UseInMemoryDatabase($"phase7-{test}-{Guid.NewGuid():N}")
|
||||||
|
.Options;
|
||||||
|
return new OtOpcUaConfigDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptService_AddAsync_generates_logical_id_and_hash()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var svc = new ScriptService(db);
|
||||||
|
|
||||||
|
var s = await svc.AddAsync(5, "line-rate", "return ctx.GetTag(\"a\").Value;", default);
|
||||||
|
|
||||||
|
s.ScriptId.ShouldStartWith("scr-");
|
||||||
|
s.GenerationId.ShouldBe(5);
|
||||||
|
s.SourceHash.Length.ShouldBe(64);
|
||||||
|
(await svc.ListAsync(5, default)).Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptService_UpdateAsync_recomputes_hash_on_source_change()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var svc = new ScriptService(db);
|
||||||
|
var s = await svc.AddAsync(5, "s", "return 1;", default);
|
||||||
|
var hashBefore = s.SourceHash;
|
||||||
|
|
||||||
|
var updated = await svc.UpdateAsync(5, s.ScriptId, "s", "return 2;", default);
|
||||||
|
updated.SourceHash.ShouldNotBe(hashBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptService_UpdateAsync_same_source_same_hash()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var svc = new ScriptService(db);
|
||||||
|
var s = await svc.AddAsync(5, "s", "return 1;", default);
|
||||||
|
var updated = await svc.UpdateAsync(5, s.ScriptId, "renamed", "return 1;", default);
|
||||||
|
|
||||||
|
updated.SourceHash.ShouldBe(s.SourceHash, "source unchanged → hash unchanged → compile cache hit preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptService_DeleteAsync_is_idempotent()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var svc = new ScriptService(db);
|
||||||
|
|
||||||
|
await Should.NotThrowAsync(() => svc.DeleteAsync(5, "nonexistent", default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VirtualTagService_round_trips_trigger_flags()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var svc = new VirtualTagService(db);
|
||||||
|
|
||||||
|
var v = await svc.AddAsync(7, "eq-1", "LineRate", "Float32", "scr-1",
|
||||||
|
changeTriggered: true, timerIntervalMs: 1000, historize: true, default);
|
||||||
|
|
||||||
|
v.ChangeTriggered.ShouldBeTrue();
|
||||||
|
v.TimerIntervalMs.ShouldBe(1000);
|
||||||
|
v.Historize.ShouldBeTrue();
|
||||||
|
v.Enabled.ShouldBeTrue();
|
||||||
|
(await svc.ListAsync(7, default)).Single().VirtualTagId.ShouldBe(v.VirtualTagId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VirtualTagService_update_enabled_toggles_flag()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var svc = new VirtualTagService(db);
|
||||||
|
var v = await svc.AddAsync(7, "eq-1", "N", "Int32", "scr-1", true, null, false, default);
|
||||||
|
|
||||||
|
var disabled = await svc.UpdateEnabledAsync(7, v.VirtualTagId, false, default);
|
||||||
|
disabled.Enabled.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptedAlarmService_defaults_HistorizeToAveva_true_per_plan_decision_15()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var svc = new ScriptedAlarmService(db);
|
||||||
|
|
||||||
|
var a = await svc.AddAsync(9, "eq-1", "HighTemp", "LimitAlarm", severity: 800,
|
||||||
|
messageTemplate: "{Temp} too high", predicateScriptId: "scr-9",
|
||||||
|
historizeToAveva: true, retain: true, default);
|
||||||
|
|
||||||
|
a.HistorizeToAveva.ShouldBeTrue();
|
||||||
|
a.Severity.ShouldBe(800);
|
||||||
|
a.ScriptedAlarmId.ShouldStartWith("sal-");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptTestHarness_runs_successful_script_and_captures_writes()
|
||||||
|
{
|
||||||
|
var harness = new ScriptTestHarnessService();
|
||||||
|
var source = """
|
||||||
|
ctx.SetVirtualTag("Out", 42);
|
||||||
|
return ctx.GetTag("In").Value;
|
||||||
|
""";
|
||||||
|
var inputs = new Dictionary<string, DataValueSnapshot>
|
||||||
|
{
|
||||||
|
["In"] = new(123, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await harness.RunVirtualTagAsync(source, inputs, default);
|
||||||
|
|
||||||
|
result.Outcome.ShouldBe(ScriptTestOutcome.Success);
|
||||||
|
result.Output.ShouldBe(123);
|
||||||
|
result.Writes["Out"].ShouldBe(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptTestHarness_rejects_missing_synthetic_input()
|
||||||
|
{
|
||||||
|
var harness = new ScriptTestHarnessService();
|
||||||
|
var source = """return ctx.GetTag("A").Value;""";
|
||||||
|
|
||||||
|
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||||
|
|
||||||
|
result.Outcome.ShouldBe(ScriptTestOutcome.MissingInputs);
|
||||||
|
result.Errors[0].ShouldContain("A");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptTestHarness_rejects_extra_synthetic_input_not_referenced_by_script()
|
||||||
|
{
|
||||||
|
var harness = new ScriptTestHarnessService();
|
||||||
|
var source = """return 1;"""; // no GetTag calls
|
||||||
|
var inputs = new Dictionary<string, DataValueSnapshot>
|
||||||
|
{
|
||||||
|
["Unexpected"] = new(0, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await harness.RunVirtualTagAsync(source, inputs, default);
|
||||||
|
|
||||||
|
result.Outcome.ShouldBe(ScriptTestOutcome.UnknownInputs);
|
||||||
|
result.Errors[0].ShouldContain("Unexpected");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptTestHarness_rejects_non_literal_path()
|
||||||
|
{
|
||||||
|
var harness = new ScriptTestHarnessService();
|
||||||
|
var source = """
|
||||||
|
var p = "A";
|
||||||
|
return ctx.GetTag(p).Value;
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||||
|
|
||||||
|
result.Outcome.ShouldBe(ScriptTestOutcome.DependencyRejected);
|
||||||
|
result.Errors.ShouldNotBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptTestHarness_surfaces_compile_error_as_Threw()
|
||||||
|
{
|
||||||
|
var harness = new ScriptTestHarnessService();
|
||||||
|
var source = "this is not valid C#;";
|
||||||
|
|
||||||
|
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||||
|
|
||||||
|
result.Outcome.ShouldBe(ScriptTestOutcome.Threw);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HistorianDiagnosticsService_reports_Disabled_for_null_sink()
|
||||||
|
{
|
||||||
|
var diag = new HistorianDiagnosticsService(NullAlarmHistorianSink.Instance);
|
||||||
|
diag.GetStatus().DrainState.ShouldBe(HistorianDrainState.Disabled);
|
||||||
|
diag.TryRetryDeadLettered().ShouldBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,6 +147,117 @@ public sealed class EquipmentNodeWalkerTests
|
|||||||
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
|
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator()
|
||||||
|
{
|
||||||
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||||
|
var vtag = new VirtualTag
|
||||||
|
{
|
||||||
|
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "LineRate",
|
||||||
|
DataType = "Float32", ScriptId = "scr-1", Historize = true,
|
||||||
|
};
|
||||||
|
var content = new EquipmentNamespaceContent(
|
||||||
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||||
|
[eq], [], VirtualTags: [vtag]);
|
||||||
|
|
||||||
|
var rec = new RecordingBuilder("root");
|
||||||
|
EquipmentNodeWalker.Walk(rec, content);
|
||||||
|
|
||||||
|
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||||
|
var v = equipmentNode.Variables.Single(x => x.BrowseName == "LineRate");
|
||||||
|
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Virtual);
|
||||||
|
v.AttributeInfo.VirtualTagId.ShouldBe("vt-1");
|
||||||
|
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
||||||
|
v.AttributeInfo.IsHistorized.ShouldBeTrue();
|
||||||
|
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm()
|
||||||
|
{
|
||||||
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||||
|
var alarm = new ScriptedAlarm
|
||||||
|
{
|
||||||
|
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "HighTemp",
|
||||||
|
AlarmType = "LimitAlarm", MessageTemplate = "{Temp} exceeded",
|
||||||
|
PredicateScriptId = "scr-9", Severity = 800,
|
||||||
|
};
|
||||||
|
var content = new EquipmentNamespaceContent(
|
||||||
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||||
|
[eq], [], ScriptedAlarms: [alarm]);
|
||||||
|
|
||||||
|
var rec = new RecordingBuilder("root");
|
||||||
|
EquipmentNodeWalker.Walk(rec, content);
|
||||||
|
|
||||||
|
var v = rec.Children[0].Children[0].Children[0].Variables.Single(x => x.BrowseName == "HighTemp");
|
||||||
|
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.ScriptedAlarm);
|
||||||
|
v.AttributeInfo.ScriptedAlarmId.ShouldBe("al-1");
|
||||||
|
v.AttributeInfo.VirtualTagId.ShouldBeNull();
|
||||||
|
v.AttributeInfo.IsAlarm.ShouldBeTrue();
|
||||||
|
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Walk_Skips_Disabled_VirtualTags_And_Alarms()
|
||||||
|
{
|
||||||
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||||
|
var vtag = new VirtualTag
|
||||||
|
{
|
||||||
|
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled",
|
||||||
|
DataType = "Float32", ScriptId = "scr-1", Enabled = false,
|
||||||
|
};
|
||||||
|
var alarm = new ScriptedAlarm
|
||||||
|
{
|
||||||
|
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "DisabledAlarm",
|
||||||
|
AlarmType = "LimitAlarm", MessageTemplate = "x",
|
||||||
|
PredicateScriptId = "scr-9", Enabled = false,
|
||||||
|
};
|
||||||
|
var content = new EquipmentNamespaceContent(
|
||||||
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||||
|
[eq], [], VirtualTags: [vtag], ScriptedAlarms: [alarm]);
|
||||||
|
|
||||||
|
var rec = new RecordingBuilder("root");
|
||||||
|
EquipmentNodeWalker.Walk(rec, content);
|
||||||
|
|
||||||
|
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe()
|
||||||
|
{
|
||||||
|
// Backwards-compat — callers that don't populate the new collections still work.
|
||||||
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||||
|
var content = new EquipmentNamespaceContent(
|
||||||
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||||
|
|
||||||
|
var rec = new RecordingBuilder("root");
|
||||||
|
EquipmentNodeWalker.Walk(rec, content); // must not throw
|
||||||
|
|
||||||
|
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Driver_tag_default_NodeSourceKind_is_Driver()
|
||||||
|
{
|
||||||
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||||
|
var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1");
|
||||||
|
var content = new EquipmentNamespaceContent(
|
||||||
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||||
|
[eq], [tag]);
|
||||||
|
|
||||||
|
var rec = new RecordingBuilder("root");
|
||||||
|
EquipmentNodeWalker.Walk(rec, content);
|
||||||
|
|
||||||
|
var v = rec.Children[0].Children[0].Children[0].Variables.Single();
|
||||||
|
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Driver);
|
||||||
|
v.AttributeInfo.VirtualTagId.ShouldBeNull();
|
||||||
|
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
// ----- builders for test seed rows -----
|
// ----- builders for test seed rows -----
|
||||||
|
|
||||||
private static UnsArea Area(string id, string name) => new()
|
private static UnsArea Area(string id, string name) => new()
|
||||||
|
|||||||
Reference in New Issue
Block a user