Compare commits
8 Commits
phase-7-st
...
phase-7-fu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6a8bb1064 | ||
| f3053580a0 | |||
|
|
f64a8049d8 | ||
| c7f0855427 | |||
|
|
63b31e240e | ||
| 78f388b761 | |||
|
|
d78741cfdf | ||
| c08ae0d032 |
@@ -36,6 +36,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 7 follow-up (task #241) — extends <c>dbo.sp_ComputeGenerationDiff</c> to emit
|
||||
/// Script / VirtualTag / ScriptedAlarm rows alongside the existing Namespace /
|
||||
/// DriverInstance / Equipment / Tag / NodeAcl output. Admin DiffViewer now shows
|
||||
/// Phase 7 changes between generations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Logical ids: ScriptId, VirtualTagId, ScriptedAlarmId — stable across generations
|
||||
/// so a Script whose source changes surfaces as Modified (CHECKSUM picks up the
|
||||
/// SourceHash delta) while a renamed script surfaces as Modified on Name alone.
|
||||
/// ScriptedAlarmState is deliberately excluded — it's not generation-scoped, so
|
||||
/// diffing it between generations is meaningless.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public partial class ExtendComputeGenerationDiffWithPhase7 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiffV3);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
|
||||
}
|
||||
|
||||
private static class Procs
|
||||
{
|
||||
/// <summary>V3 — adds Script / VirtualTag / ScriptedAlarm sections.</summary>
|
||||
public const string ComputeGenerationDiffV3 = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||
t AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- Phase 7 — Script section. CHECKSUM picks up source changes via SourceHash + rename
|
||||
-- via Name; Language future-proofs for non-C# engines. Same Name + same Source =
|
||||
-- Unchanged (identical hash).
|
||||
WITH f AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Script', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- Phase 7 — VirtualTag section.
|
||||
WITH f AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'VirtualTag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- Phase 7 — ScriptedAlarm section. ScriptedAlarmState (operator ack trail) is
|
||||
-- logical-id keyed outside the generation scope + intentionally excluded here —
|
||||
-- diffing ack state between generations is semantically meaningless.
|
||||
WITH f AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'ScriptedAlarm', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
|
||||
/// <summary>V2 — restores the pre-Phase-7 proc on Down().</summary>
|
||||
public const string ComputeGenerationDiffV2 = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||
t AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
|
||||
private readonly Func<string, string?>? _resilienceConfigLookup;
|
||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
|
||||
|
||||
// Phase 7 Stream G follow-up (task #239). When composed with the VirtualTagEngine +
|
||||
// ScriptedAlarmEngine sources these route node reads to the engines instead of the
|
||||
// driver. Null = Phase 7 engines not enabled for this deployment (identical to pre-
|
||||
// Phase-7 behaviour).
|
||||
private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
|
||||
private readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
@@ -45,7 +53,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
StaleConfigFlag? staleConfigFlag = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
|
||||
Func<string, string?>? resilienceConfigLookup = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null)
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable = null,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable = null)
|
||||
{
|
||||
_options = options;
|
||||
_driverHost = driverHost;
|
||||
@@ -57,6 +67,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
_tierLookup = tierLookup;
|
||||
_resilienceConfigLookup = resilienceConfigLookup;
|
||||
_equipmentContentLookup = equipmentContentLookup;
|
||||
_virtualReadable = virtualReadable;
|
||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -85,7 +97,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
|
||||
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
|
||||
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
||||
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup);
|
||||
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
|
||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
|
||||
await _application.Start(_server).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||
|
||||
@@ -25,6 +25,15 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
private readonly NodeScopeResolver? _scopeResolver;
|
||||
private readonly Func<string, DriverTier>? _tierLookup;
|
||||
private readonly Func<string, string?>? _resilienceConfigLookup;
|
||||
|
||||
// Phase 7 Stream G follow-up wiring (task #239). Shared across every DriverNodeManager
|
||||
// instantiated by this server so virtual-tag reads and scripted-alarm reads from any
|
||||
// driver's address-space subtree route to the same engine. When null (no Phase 7
|
||||
// engines composed for this deployment) DriverNodeManager falls back to driver-only
|
||||
// dispatch — identical to pre-Phase-7 behaviour.
|
||||
private readonly IReadable? _virtualReadable;
|
||||
private readonly IReadable? _scriptedAlarmReadable;
|
||||
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
||||
|
||||
@@ -36,7 +45,9 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
AuthorizationGate? authzGate = null,
|
||||
NodeScopeResolver? scopeResolver = null,
|
||||
Func<string, DriverTier>? tierLookup = null,
|
||||
Func<string, string?>? resilienceConfigLookup = null)
|
||||
Func<string, string?>? resilienceConfigLookup = null,
|
||||
IReadable? virtualReadable = null,
|
||||
IReadable? scriptedAlarmReadable = null)
|
||||
{
|
||||
_driverHost = driverHost;
|
||||
_authenticator = authenticator;
|
||||
@@ -45,6 +56,8 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
_scopeResolver = scopeResolver;
|
||||
_tierLookup = tierLookup;
|
||||
_resilienceConfigLookup = resilienceConfigLookup;
|
||||
_virtualReadable = virtualReadable;
|
||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
@@ -77,7 +90,8 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
|
||||
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
|
||||
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
||||
authzGate: _authzGate, scopeResolver: _scopeResolver);
|
||||
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
|
||||
_driverNodeManagers.Add(manager);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Production <c>ITagUpstreamSource</c> for the Phase 7 engines (implements both the
|
||||
/// Core.VirtualTags and Core.ScriptedAlarms variants — identical shape, distinct
|
||||
/// namespaces). Per the interface docstring, reads are synchronous — user scripts
|
||||
/// call <c>ctx.GetTag</c> inline — so we serve from a last-known-value cache that
|
||||
/// the driver-bridge populates asynchronously via <see cref="Push"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="Push"/> is called by the driver-bridge (wiring added by task #244)
|
||||
/// every time a driver's <c>ISubscribable.OnDataChange</c> fires. Subscribers
|
||||
/// registered via <see cref="SubscribeTag"/> are notified synchronously on the
|
||||
/// calling thread — the VirtualTagEngine + ScriptedAlarmEngine handle their own
|
||||
/// async hand-off via <c>SemaphoreSlim</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reads of a path that has never been <see cref="Push"/>-ed return
|
||||
/// <see cref="UpstreamNotConfigured"/>-quality — which scripts see as
|
||||
/// <c>ctx.GetTag("...").StatusCode == BadNodeIdUnknown</c> and can branch on.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CachedTagUpstreamSource
|
||||
: Core.VirtualTags.ITagUpstreamSource,
|
||||
Core.ScriptedAlarms.ITagUpstreamSource
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _observers
|
||||
= new(StringComparer.Ordinal);
|
||||
|
||||
public DataValueSnapshot ReadTag(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) throw new ArgumentException("path required", nameof(path));
|
||||
return _values.TryGetValue(path, out var snap)
|
||||
? snap
|
||||
: new DataValueSnapshot(null, UpstreamNotConfigured, null, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) throw new ArgumentException("path required", nameof(path));
|
||||
ArgumentNullException.ThrowIfNull(observer);
|
||||
|
||||
var list = _observers.GetOrAdd(path, _ => []);
|
||||
lock (list) list.Add(observer);
|
||||
return new Unsub(this, path, observer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver-bridge write path — called when a driver delivers a value change for
|
||||
/// <paramref name="path"/>. Updates the cache + fans out to every observer.
|
||||
/// Safe for concurrent callers; observers fire on the caller's thread.
|
||||
/// </summary>
|
||||
public void Push(string path, DataValueSnapshot snapshot)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) throw new ArgumentException("path required", nameof(path));
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
_values[path] = snapshot;
|
||||
if (!_observers.TryGetValue(path, out var list)) return;
|
||||
Action<string, DataValueSnapshot>[] snapshotList;
|
||||
lock (list) snapshotList = list.ToArray();
|
||||
foreach (var observer in snapshotList) observer(path, snapshot);
|
||||
}
|
||||
|
||||
/// <summary>Mirror of OPC UA <c>StatusCodes.BadNodeIdUnknown</c> without pulling the OPC stack dependency.</summary>
|
||||
public const uint UpstreamNotConfigured = 0x80340000;
|
||||
|
||||
private sealed class Unsub(CachedTagUpstreamSource owner, string path, Action<string, DataValueSnapshot> observer) : IDisposable
|
||||
{
|
||||
private bool _disposed;
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (owner._observers.TryGetValue(path, out var list))
|
||||
lock (list) list.Remove(observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
208
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs
Normal file
208
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 follow-up (task #243) — maps the generation's <see cref="Script"/> /
|
||||
/// <see cref="VirtualTag"/> / <see cref="ScriptedAlarm"/> rows into the runtime
|
||||
/// definitions <see cref="VirtualTagEngine"/> + <see cref="ScriptedAlarmEngine"/>
|
||||
/// expect, builds the engine instances, and returns the <see cref="IReadable"/>
|
||||
/// sources plus an <see cref="IAlarmSource"/> for the <c>DriverNodeManager</c>
|
||||
/// wiring added by task #239.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Empty Phase 7 config (no virtual tags + no scripted alarms) is a valid state:
|
||||
/// <see cref="Compose"/> returns a <see cref="Phase7ComposedSources"/> with null
|
||||
/// sources so Program.cs can pass them through to <c>OpcUaApplicationHost</c>
|
||||
/// unchanged — deployments without scripts behave exactly as they did before
|
||||
/// Phase 7.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The caller owns the returned <see cref="Phase7ComposedSources.Disposables"/>
|
||||
/// and must dispose them on shutdown. Engine cascades + timer ticks run off
|
||||
/// background threads until then.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Phase7EngineComposer
|
||||
{
|
||||
public static Phase7ComposedSources Compose(
|
||||
IReadOnlyList<Script> scripts,
|
||||
IReadOnlyList<VirtualTag> virtualTags,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
||||
CachedTagUpstreamSource upstream,
|
||||
IAlarmStateStore alarmStateStore,
|
||||
IAlarmHistorianSink historianSink,
|
||||
Serilog.ILogger rootScriptLogger,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scripts);
|
||||
ArgumentNullException.ThrowIfNull(virtualTags);
|
||||
ArgumentNullException.ThrowIfNull(scriptedAlarms);
|
||||
ArgumentNullException.ThrowIfNull(upstream);
|
||||
ArgumentNullException.ThrowIfNull(alarmStateStore);
|
||||
ArgumentNullException.ThrowIfNull(historianSink);
|
||||
ArgumentNullException.ThrowIfNull(rootScriptLogger);
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
|
||||
if (virtualTags.Count == 0 && scriptedAlarms.Count == 0)
|
||||
return Phase7ComposedSources.Empty;
|
||||
|
||||
var scriptById = scripts
|
||||
.Where(s => s.Enabled())
|
||||
.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
|
||||
|
||||
var scriptLoggerFactory = new ScriptLoggerFactory(rootScriptLogger);
|
||||
var disposables = new List<IDisposable>();
|
||||
|
||||
// Engines take Serilog.ILogger — each engine gets its own so rolling-file emissions
|
||||
// stay keyed to the right source in the scripts-*.log.
|
||||
VirtualTagSource? vtSource = null;
|
||||
if (virtualTags.Count > 0)
|
||||
{
|
||||
var vtDefs = ProjectVirtualTags(virtualTags, scriptById).ToList();
|
||||
var vtEngine = new VirtualTagEngine(upstream, scriptLoggerFactory, rootScriptLogger);
|
||||
vtEngine.Load(vtDefs);
|
||||
vtSource = new VirtualTagSource(vtEngine);
|
||||
disposables.Add(vtEngine);
|
||||
}
|
||||
|
||||
IReadable? alarmReadable = null;
|
||||
if (scriptedAlarms.Count > 0)
|
||||
{
|
||||
var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList();
|
||||
var alarmEngine = new ScriptedAlarmEngine(upstream, alarmStateStore, scriptLoggerFactory, rootScriptLogger);
|
||||
// Wire alarm emissions to the historian sink (Stream D). Fire-and-forget because
|
||||
// the sink's EnqueueAsync is already non-blocking from the producer's view.
|
||||
var engineLogger = loggerFactory.CreateLogger("Phase7HistorianRouter");
|
||||
alarmEngine.OnEvent += (_, e) => _ = RouteToHistorianAsync(e, historianSink, engineLogger);
|
||||
alarmEngine.LoadAsync(alarmDefs, CancellationToken.None).GetAwaiter().GetResult();
|
||||
var alarmSource = new ScriptedAlarmSource(alarmEngine);
|
||||
// Task #245 — expose each alarm's current Active state as IReadable so OPC UA
|
||||
// variable reads on Source=ScriptedAlarm nodes return the live predicate truth
|
||||
// instead of BadNotFound. ScriptedAlarmSource stays registered as IAlarmSource
|
||||
// for the event stream; the IReadable is a separate adapter over the same engine.
|
||||
alarmReadable = new ScriptedAlarmReadable(alarmEngine);
|
||||
disposables.Add(alarmEngine);
|
||||
disposables.Add(alarmSource);
|
||||
}
|
||||
|
||||
return new Phase7ComposedSources(vtSource, alarmReadable, disposables);
|
||||
}
|
||||
|
||||
internal static IEnumerable<VirtualTagDefinition> ProjectVirtualTags(
|
||||
IReadOnlyList<VirtualTag> rows, IReadOnlyDictionary<string, Script> scriptById)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!row.Enabled) continue;
|
||||
if (!scriptById.TryGetValue(row.ScriptId, out var script))
|
||||
throw new InvalidOperationException(
|
||||
$"VirtualTag '{row.VirtualTagId}' references unknown / disabled Script '{row.ScriptId}' in this generation");
|
||||
|
||||
yield return new VirtualTagDefinition(
|
||||
Path: row.VirtualTagId,
|
||||
DataType: ParseDataType(row.DataType),
|
||||
ScriptSource: script.SourceCode,
|
||||
ChangeTriggered: row.ChangeTriggered,
|
||||
TimerInterval: row.TimerIntervalMs.HasValue
|
||||
? TimeSpan.FromMilliseconds(row.TimerIntervalMs.Value)
|
||||
: null,
|
||||
Historize: row.Historize);
|
||||
}
|
||||
}
|
||||
|
||||
internal static IEnumerable<ScriptedAlarmDefinition> ProjectScriptedAlarms(
|
||||
IReadOnlyList<ScriptedAlarm> rows, IReadOnlyDictionary<string, Script> scriptById)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!row.Enabled) continue;
|
||||
if (!scriptById.TryGetValue(row.PredicateScriptId, out var script))
|
||||
throw new InvalidOperationException(
|
||||
$"ScriptedAlarm '{row.ScriptedAlarmId}' references unknown / disabled predicate Script '{row.PredicateScriptId}'");
|
||||
|
||||
yield return new ScriptedAlarmDefinition(
|
||||
AlarmId: row.ScriptedAlarmId,
|
||||
EquipmentPath: row.EquipmentId,
|
||||
AlarmName: row.Name,
|
||||
Kind: ParseAlarmKind(row.AlarmType),
|
||||
Severity: MapSeverity(row.Severity),
|
||||
MessageTemplate: row.MessageTemplate,
|
||||
PredicateScriptSource: script.SourceCode,
|
||||
HistorizeToAveva: row.HistorizeToAveva,
|
||||
Retain: row.Retain);
|
||||
}
|
||||
}
|
||||
|
||||
private static DriverDataType ParseDataType(string raw) =>
|
||||
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
||||
|
||||
private static AlarmKind ParseAlarmKind(string raw) => raw switch
|
||||
{
|
||||
"AlarmCondition" => AlarmKind.AlarmCondition,
|
||||
"LimitAlarm" => AlarmKind.LimitAlarm,
|
||||
"DiscreteAlarm" => AlarmKind.DiscreteAlarm,
|
||||
"OffNormalAlarm" => AlarmKind.OffNormalAlarm,
|
||||
_ => throw new InvalidOperationException($"Unknown AlarmType '{raw}' — DB check constraint should have caught this"),
|
||||
};
|
||||
|
||||
// OPC UA Part 9 severity bands (1..1000) → AlarmSeverity enum. Matches the same
|
||||
// banding the AB CIP ALMA projection + OpcUaClient MapSeverity use.
|
||||
private static AlarmSeverity MapSeverity(int s) => s switch
|
||||
{
|
||||
<= 250 => AlarmSeverity.Low,
|
||||
<= 500 => AlarmSeverity.Medium,
|
||||
<= 750 => AlarmSeverity.High,
|
||||
_ => AlarmSeverity.Critical,
|
||||
};
|
||||
|
||||
private static async Task RouteToHistorianAsync(
|
||||
ScriptedAlarmEvent e, IAlarmHistorianSink sink, Microsoft.Extensions.Logging.ILogger log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var historianEvent = new AlarmHistorianEvent(
|
||||
AlarmId: e.AlarmId,
|
||||
EquipmentPath: e.EquipmentPath,
|
||||
AlarmName: e.AlarmName,
|
||||
AlarmTypeName: e.Kind.ToString(),
|
||||
Severity: e.Severity,
|
||||
EventKind: e.Emission.ToString(),
|
||||
Message: e.Message,
|
||||
User: e.Condition.LastAckUser ?? "system",
|
||||
Comment: e.Condition.LastAckComment,
|
||||
TimestampUtc: e.TimestampUtc);
|
||||
await sink.EnqueueAsync(historianEvent, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogWarning(ex, "Historian enqueue failed for alarm {AlarmId}/{Emission}", e.AlarmId, e.Emission);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What <see cref="Phase7EngineComposer.Compose"/> returns.</summary>
|
||||
/// <param name="VirtualReadable">Non-null when virtual tags were composed; pass to <c>OpcUaApplicationHost.virtualReadable</c>.</param>
|
||||
/// <param name="ScriptedAlarmReadable">Non-null when scripted alarms were composed; pass to <c>OpcUaApplicationHost.scriptedAlarmReadable</c>.</param>
|
||||
/// <param name="Disposables">Engine + source instances the caller owns. Dispose on shutdown.</param>
|
||||
public sealed record Phase7ComposedSources(
|
||||
IReadable? VirtualReadable,
|
||||
IReadable? ScriptedAlarmReadable,
|
||||
IReadOnlyList<IDisposable> Disposables)
|
||||
{
|
||||
public static readonly Phase7ComposedSources Empty =
|
||||
new(null, null, Array.Empty<IDisposable>());
|
||||
}
|
||||
|
||||
internal static class ScriptEnabledExtensions
|
||||
{
|
||||
// Script has no explicit Enabled column; every row in the generation is a live script.
|
||||
public static bool Enabled(this Script _) => true;
|
||||
}
|
||||
58
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs
Normal file
58
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IReadable"/> adapter exposing each scripted alarm's current
|
||||
/// <see cref="AlarmActiveState"/> as an OPC UA boolean. Phase 7 follow-up (task #245).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Paired with the <see cref="NodeSourceKind.ScriptedAlarm"/> dispatch in
|
||||
/// <c>DriverNodeManager.OnReadValue</c>. Full-reference lookup is the
|
||||
/// <c>ScriptedAlarmId</c> the walker wrote into <c>DriverAttributeInfo.FullName</c>
|
||||
/// when emitting the alarm variable node.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Unknown alarm ids return <c>BadNodeIdUnknown</c> so misconfiguration surfaces
|
||||
/// instead of silently reading <c>false</c>. Alarms whose predicate has never
|
||||
/// been evaluated (brand new, before the engine's first cascade tick) report
|
||||
/// <see cref="AlarmActiveState.Inactive"/> via <see cref="AlarmConditionState.Fresh"/>,
|
||||
/// which matches the Part 9 initial-state semantics.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptedAlarmReadable : IReadable
|
||||
{
|
||||
/// <summary>OPC UA <c>StatusCodes.BadNodeIdUnknown</c> — kept local so we don't pull the OPC stack.</summary>
|
||||
private const uint BadNodeIdUnknown = 0x80340000;
|
||||
|
||||
private readonly ScriptedAlarmEngine _engine;
|
||||
|
||||
public ScriptedAlarmReadable(ScriptedAlarmEngine engine)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var alarmId = fullReferences[i];
|
||||
var state = _engine.GetState(alarmId);
|
||||
if (state is null)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
var active = state.Active == AlarmActiveState.Active;
|
||||
results[i] = new DataValueSnapshot(active, 0u, now, now);
|
||||
}
|
||||
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||
</ItemGroup>
|
||||
|
||||
130
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs
Normal file
130
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// Stands up the Admin Blazor Server host on a free TCP port with the live SQL Server
|
||||
/// context swapped for an EF Core InMemory DbContext + the LDAP cookie auth swapped for
|
||||
/// <see cref="TestAuthHandler"/>. Playwright connects to <see cref="BaseUrl"/>.
|
||||
/// InMemory is sufficient because UnsService's drag-drop path exercises EF operations,
|
||||
/// not raw SQL.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We deliberately build a <see cref="WebApplication"/> directly rather than going through
|
||||
/// <c>WebApplicationFactory<Program></c> — the factory's TestServer transport doesn't
|
||||
/// coexist cleanly with Kestrel-on-a-real-port, and Playwright needs a real loopback HTTP
|
||||
/// endpoint to hit. This mirrors the Program.cs entry-points for everything else.
|
||||
/// </remarks>
|
||||
public sealed class AdminWebAppFactory : IAsyncDisposable
|
||||
{
|
||||
private WebApplication? _app;
|
||||
|
||||
public string BaseUrl { get; private set; } = "";
|
||||
public long SeededGenerationId { get; private set; }
|
||||
public string SeededClusterId { get; } = "e2e-cluster";
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var port = GetFreeTcpPort();
|
||||
BaseUrl = $"http://127.0.0.1:{port}";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(Array.Empty<string>());
|
||||
builder.WebHost.UseUrls(BaseUrl);
|
||||
|
||||
// --- Mirror the Admin composition in Program.cs, but with the InMemory DB + test
|
||||
// auth swaps instead of SQL Server + LDAP cookie auth.
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddAntiforgery();
|
||||
|
||||
builder.Services.AddAuthentication(TestAuthHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("CanEdit", p => p.RequireRole(Admin.Services.AdminRoles.ConfigEditor, Admin.Services.AdminRoles.FleetAdmin))
|
||||
.AddPolicy("CanPublish", p => p.RequireRole(Admin.Services.AdminRoles.FleetAdmin));
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseInMemoryDatabase($"e2e-{Guid.NewGuid():N}"));
|
||||
|
||||
builder.Services.AddScoped<Admin.Services.ClusterService>();
|
||||
builder.Services.AddScoped<Admin.Services.GenerationService>();
|
||||
builder.Services.AddScoped<Admin.Services.UnsService>();
|
||||
builder.Services.AddScoped<Admin.Services.EquipmentService>();
|
||||
builder.Services.AddScoped<Admin.Services.NamespaceService>();
|
||||
builder.Services.AddScoped<Admin.Services.DriverInstanceService>();
|
||||
builder.Services.AddScoped<Admin.Services.DraftValidationService>();
|
||||
|
||||
_app = builder.Build();
|
||||
_app.UseStaticFiles();
|
||||
_app.UseRouting();
|
||||
_app.UseAuthentication();
|
||||
_app.UseAuthorization();
|
||||
_app.UseAntiforgery();
|
||||
_app.MapRazorComponents<Admin.Components.App>().AddInteractiveServerRenderMode();
|
||||
|
||||
// Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav.
|
||||
using (var scope = _app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
SeededGenerationId = Seed(db, SeededClusterId);
|
||||
}
|
||||
|
||||
await _app.StartAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static long Seed(OtOpcUaConfigDbContext db, string clusterId)
|
||||
{
|
||||
var cluster = new ServerCluster
|
||||
{
|
||||
ClusterId = clusterId, Name = "e2e", Enterprise = "zb", Site = "lab",
|
||||
RedundancyMode = RedundancyMode.None, NodeCount = 1, CreatedBy = "e2e",
|
||||
};
|
||||
var gen = new ConfigGeneration
|
||||
{
|
||||
ClusterId = clusterId, Status = GenerationStatus.Draft, CreatedBy = "e2e",
|
||||
};
|
||||
|
||||
db.ServerClusters.Add(cluster);
|
||||
db.ConfigGenerations.Add(gen);
|
||||
db.SaveChanges();
|
||||
|
||||
db.UnsAreas.AddRange(
|
||||
new UnsArea { UnsAreaId = "area-a", ClusterId = clusterId, Name = "warsaw", GenerationId = gen.GenerationId },
|
||||
new UnsArea { UnsAreaId = "area-b", ClusterId = clusterId, Name = "berlin", GenerationId = gen.GenerationId });
|
||||
db.UnsLines.Add(new UnsLine
|
||||
{
|
||||
UnsLineId = "line-a1", UnsAreaId = "area-a", Name = "oven-line", GenerationId = gen.GenerationId,
|
||||
});
|
||||
db.SaveChanges();
|
||||
return gen.GenerationId;
|
||||
}
|
||||
|
||||
private static int GetFreeTcpPort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
44
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs
Normal file
44
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// One Playwright runtime + Chromium browser for the whole E2E suite. Tests
|
||||
/// open a fresh <see cref="IBrowserContext"/> per fixture so cookies + localStorage
|
||||
/// stay isolated. Browser install is a one-time step:
|
||||
/// <c>pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium</c>.
|
||||
/// When the browser binary isn't present the suite reports a <see cref="PlaywrightBrowserMissingException"/>
|
||||
/// so CI can distinguish missing-browser from real test failure.
|
||||
/// </summary>
|
||||
public sealed class PlaywrightFixture : IAsyncLifetime
|
||||
{
|
||||
public IPlaywright Playwright { get; private set; } = null!;
|
||||
public IBrowser Browser { get; private set; } = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
|
||||
try
|
||||
{
|
||||
Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
|
||||
}
|
||||
catch (PlaywrightException ex) when (ex.Message.Contains("Executable doesn't exist"))
|
||||
{
|
||||
throw new PlaywrightBrowserMissingException(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Browser is not null) await Browser.CloseAsync();
|
||||
Playwright?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="PlaywrightFixture"/> when Chromium isn't installed. Tests
|
||||
/// catching this mark themselves as "skipped" rather than "failed", so CI without
|
||||
/// the install step stays green.
|
||||
/// </summary>
|
||||
public sealed class PlaywrightBrowserMissingException(string message) : Exception(message);
|
||||
34
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs
Normal file
34
tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// Stamps every request with a FleetAdmin principal so E2E tests can hit
|
||||
/// authenticated Razor pages without the LDAP login flow. Registered as the
|
||||
/// default authentication scheme by <see cref="AdminWebAppFactory"/>.
|
||||
/// </summary>
|
||||
public sealed class TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "Test";
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, "e2e-test-user"),
|
||||
new Claim(ClaimTypes.Role, AdminRoles.FleetAdmin),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.Playwright;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4 UnsTab drag-drop E2E smoke (task #199). This PR lands the Playwright +
|
||||
/// WebApplicationFactory-equivalent scaffolding so future E2E coverage builds on it
|
||||
/// rather than setting it up from scratch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Prerequisite.</b> Chromium must be installed locally:
|
||||
/// <c>pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium</c>.
|
||||
/// When the binary is missing the tests <see cref="Assert.Skip"/> rather than fail hard,
|
||||
/// so CI pipelines that don't run the install step still report green.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Current scope.</b> The host-reachability smoke below proves the infra works:
|
||||
/// Kestrel-on-a-free-port, InMemory DbContext swap, <see cref="TestAuthHandler"/>
|
||||
/// bypass, and Playwright-to-real-browser are all exercised. The actual drag-drop
|
||||
/// interactive assertion is filed as a follow-up (task #242) because
|
||||
/// Blazor Server interactive render through a test-owned pipeline needs a dedicated
|
||||
/// diagnosis pass — the scaffolding lands here first so that follow-up can focus on
|
||||
/// the Blazor-specific wiring instead of rebuilding the harness.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class UnsTabDragDropE2ETests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Admin_host_serves_HTTP_via_Playwright_scaffolding()
|
||||
{
|
||||
await using var app = new AdminWebAppFactory();
|
||||
await app.StartAsync();
|
||||
|
||||
PlaywrightFixture fixture;
|
||||
try
|
||||
{
|
||||
fixture = new PlaywrightFixture();
|
||||
await fixture.InitializeAsync();
|
||||
}
|
||||
catch (PlaywrightBrowserMissingException)
|
||||
{
|
||||
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ctx = await fixture.Browser.NewContextAsync();
|
||||
var page = await ctx.NewPageAsync();
|
||||
|
||||
// Navigate to the root. We only assert the host is live + returns HTML — not
|
||||
// that the Blazor Server interactive render has booted. Booting the interactive
|
||||
// circuit in a test-owned pipeline is task #242.
|
||||
var response = await page.GotoAsync(app.BaseUrl);
|
||||
|
||||
response.ShouldNotBeNull();
|
||||
response!.Status.ShouldBeLessThan(500,
|
||||
$"Admin host returned HTTP {response.Status} at root — scaffolding broken");
|
||||
|
||||
// Static HTML shell should at least include the <body> and some content. This
|
||||
// rules out 404s + verifies the MapRazorComponents route pipeline is wired.
|
||||
var body = await page.Locator("body").InnerHTMLAsync();
|
||||
body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin.E2ETests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.51.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Admin\ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,83 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the Phase 7 driver-to-engine bridge cache (task #243). Verifies the
|
||||
/// cache serves last-known values synchronously, fans out Push updates to
|
||||
/// subscribers, and cleans up on Dispose.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CachedTagUpstreamSourceTests
|
||||
{
|
||||
private static DataValueSnapshot Snap(object? v) =>
|
||||
new(v, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void ReadTag_unknown_path_returns_BadNodeIdUnknown_snapshot()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
var snap = c.ReadTag("/nowhere");
|
||||
snap.Value.ShouldBeNull();
|
||||
snap.StatusCode.ShouldBe(CachedTagUpstreamSource.UpstreamNotConfigured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_then_Read_returns_cached_value()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
c.Push("/Line1/Temp", Snap(42));
|
||||
c.ReadTag("/Line1/Temp").Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_fans_out_to_subscribers_in_registration_order()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
var events = new List<string>();
|
||||
c.SubscribeTag("/X", (p, s) => events.Add($"A:{p}:{s.Value}"));
|
||||
c.SubscribeTag("/X", (p, s) => events.Add($"B:{p}:{s.Value}"));
|
||||
|
||||
c.Push("/X", Snap(7));
|
||||
|
||||
events.ShouldBe(["A:/X:7", "B:/X:7"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_to_different_path_does_not_fire_foreign_observer()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
var fired = 0;
|
||||
c.SubscribeTag("/X", (_, _) => fired++);
|
||||
|
||||
c.Push("/Y", Snap(1));
|
||||
fired.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_of_subscription_stops_fan_out()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
var fired = 0;
|
||||
var sub = c.SubscribeTag("/X", (_, _) => fired++);
|
||||
|
||||
c.Push("/X", Snap(1));
|
||||
sub.Dispose();
|
||||
c.Push("/X", Snap(2));
|
||||
|
||||
fired.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Satisfies_both_VirtualTag_and_ScriptedAlarm_upstream_interfaces()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
// Single instance is assignable to both — the composer passes it through for
|
||||
// both engine constructors per the task #243 wiring.
|
||||
((Core.VirtualTags.ITagUpstreamSource)c).ShouldNotBeNull();
|
||||
((Core.ScriptedAlarms.ITagUpstreamSource)c).ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 follow-up (task #243) — verifies the composer that maps Config DB
|
||||
/// rows to runtime engine definitions + wires up VirtualTagEngine +
|
||||
/// ScriptedAlarmEngine + historian routing.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Phase7EngineComposerTests
|
||||
{
|
||||
private static Script ScriptRow(string id, string source) => new()
|
||||
{
|
||||
ScriptRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h",
|
||||
};
|
||||
|
||||
private static VirtualTag VtRow(string id, string scriptId) => new()
|
||||
{
|
||||
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
VirtualTagId = id, EquipmentId = "eq-1", Name = id,
|
||||
DataType = "Float32", ScriptId = scriptId,
|
||||
};
|
||||
|
||||
private static ScriptedAlarm AlarmRow(string id, string scriptId) => new()
|
||||
{
|
||||
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id,
|
||||
AlarmType = "LimitAlarm", Severity = 500,
|
||||
MessageTemplate = "x", PredicateScriptId = scriptId,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Compose_empty_rows_returns_Empty_sentinel()
|
||||
{
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts: [],
|
||||
virtualTags: [],
|
||||
scriptedAlarms: [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance);
|
||||
|
||||
result.ShouldBeSameAs(Phase7ComposedSources.Empty);
|
||||
result.VirtualReadable.ShouldBeNull();
|
||||
result.ScriptedAlarmReadable.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_VirtualTag_rows_returns_non_null_VirtualReadable()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
||||
var vtags = new[] { VtRow("vt-1", "scr-1") };
|
||||
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts, vtags, [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance);
|
||||
|
||||
result.VirtualReadable.ShouldNotBeNull();
|
||||
result.ScriptedAlarmReadable.ShouldBeNull("no alarms configured");
|
||||
result.Disposables.Count.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_ScriptedAlarm_rows_returns_non_null_ScriptedAlarmReadable()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return false;") };
|
||||
var alarms = new[] { AlarmRow("al-1", "scr-1") };
|
||||
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts, [], alarms,
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance);
|
||||
|
||||
result.ScriptedAlarmReadable.ShouldNotBeNull("task #245 — alarm Active state readable");
|
||||
result.VirtualReadable.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_missing_script_reference_throws_with_actionable_message()
|
||||
{
|
||||
var vtags = new[] { VtRow("vt-1", "scr-missing") };
|
||||
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
Phase7EngineComposer.Compose(
|
||||
scripts: [],
|
||||
vtags, [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance))
|
||||
.Message.ShouldContain("scr-missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_disabled_VirtualTag_is_skipped()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
||||
var disabled = VtRow("vt-1", "scr-1");
|
||||
disabled.Enabled = false;
|
||||
|
||||
var defs = Phase7EngineComposer.ProjectVirtualTags(
|
||||
new[] { disabled },
|
||||
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).ToList();
|
||||
|
||||
defs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectVirtualTags_maps_timer_interval_milliseconds_to_TimeSpan()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
||||
var vt = VtRow("vt-1", "scr-1");
|
||||
vt.TimerIntervalMs = 2500;
|
||||
|
||||
var def = Phase7EngineComposer.ProjectVirtualTags(
|
||||
new[] { vt },
|
||||
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).Single();
|
||||
|
||||
def.TimerInterval.ShouldBe(TimeSpan.FromMilliseconds(2500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectScriptedAlarms_maps_Severity_numeric_to_AlarmSeverity_bucket()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return true;") };
|
||||
|
||||
var buckets = new[] { (1, AlarmSeverity.Low), (250, AlarmSeverity.Low),
|
||||
(251, AlarmSeverity.Medium), (500, AlarmSeverity.Medium),
|
||||
(501, AlarmSeverity.High), (750, AlarmSeverity.High),
|
||||
(751, AlarmSeverity.Critical), (1000, AlarmSeverity.Critical) };
|
||||
foreach (var (input, expected) in buckets)
|
||||
{
|
||||
var row = AlarmRow("a1", "scr-1");
|
||||
row.Severity = input;
|
||||
var def = Phase7EngineComposer.ProjectScriptedAlarms(
|
||||
new[] { row },
|
||||
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).Single();
|
||||
def.Severity.ShouldBe(expected, $"severity {input} should map to {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Task #245 — covers the IReadable adapter that surfaces each scripted alarm's
|
||||
/// live <c>ActiveState</c> so OPC UA variable reads on Source=ScriptedAlarm nodes
|
||||
/// return the predicate truth instead of BadNotFound.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmReadableTests
|
||||
{
|
||||
private static (ScriptedAlarmEngine engine, CachedTagUpstreamSource upstream) BuildEngineWith(
|
||||
params (string alarmId, string predicateSource)[] alarms)
|
||||
{
|
||||
var upstream = new CachedTagUpstreamSource();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(logger);
|
||||
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
|
||||
var defs = alarms.Select(a => new ScriptedAlarmDefinition(
|
||||
AlarmId: a.alarmId,
|
||||
EquipmentPath: "/eq",
|
||||
AlarmName: a.alarmId,
|
||||
Kind: AlarmKind.LimitAlarm,
|
||||
Severity: AlarmSeverity.Medium,
|
||||
MessageTemplate: "x",
|
||||
PredicateScriptSource: a.predicateSource)).ToList();
|
||||
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
||||
return (engine, upstream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_return_false_for_newly_loaded_alarm_with_inactive_predicate()
|
||||
{
|
||||
var (engine, _) = BuildEngineWith(("a1", "return false;"));
|
||||
using var _e = engine;
|
||||
var readable = new ScriptedAlarmReadable(engine);
|
||||
|
||||
var result = await readable.ReadAsync(["a1"], CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].Value.ShouldBe(false);
|
||||
result[0].StatusCode.ShouldBe(0u, "Good quality when the engine has state");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_return_true_when_predicate_evaluates_to_active()
|
||||
{
|
||||
var (engine, upstream) = BuildEngineWith(
|
||||
("tempAlarm", "return ctx.GetTag(\"/Site/Line/Cell/Temp\").Value is double d && d > 100;"));
|
||||
using var _e = engine;
|
||||
|
||||
// Seed the upstream value + nudge the engine so the alarm transitions to Active.
|
||||
upstream.Push("/Site/Line/Cell/Temp",
|
||||
new DataValueSnapshot(150.0, 0u, DateTime.UtcNow, DateTime.UtcNow));
|
||||
|
||||
// Allow the engine's change-driven cascade to run.
|
||||
await Task.Delay(50);
|
||||
|
||||
var readable = new ScriptedAlarmReadable(engine);
|
||||
var result = await readable.ReadAsync(["tempAlarm"], CancellationToken.None);
|
||||
|
||||
result[0].Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_return_BadNodeIdUnknown_for_missing_alarm()
|
||||
{
|
||||
var (engine, _) = BuildEngineWith(("a1", "return false;"));
|
||||
using var _e = engine;
|
||||
var readable = new ScriptedAlarmReadable(engine);
|
||||
|
||||
var result = await readable.ReadAsync(["a-not-loaded"], CancellationToken.None);
|
||||
|
||||
result[0].Value.ShouldBeNull();
|
||||
result[0].StatusCode.ShouldBe(0x80340000u,
|
||||
"BadNodeIdUnknown surfaces a misconfiguration, not a silent false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_batch_round_trip_preserves_order()
|
||||
{
|
||||
var (engine, _) = BuildEngineWith(
|
||||
("a1", "return false;"),
|
||||
("a2", "return false;"));
|
||||
using var _e = engine;
|
||||
var readable = new ScriptedAlarmReadable(engine);
|
||||
|
||||
var result = await readable.ReadAsync(["a2", "missing", "a1"], CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(3);
|
||||
result[0].Value.ShouldBe(false); // a2
|
||||
result[1].StatusCode.ShouldBe(0x80340000u); // missing
|
||||
result[2].Value.ShouldBe(false); // a1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_engine_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptedAlarmReadable(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_fullReferences_rejected()
|
||||
{
|
||||
var (engine, _) = BuildEngineWith(("a1", "return false;"));
|
||||
using var _e = engine;
|
||||
var readable = new ScriptedAlarmReadable(engine);
|
||||
|
||||
await Should.ThrowAsync<ArgumentNullException>(
|
||||
() => readable.ReadAsync(null!, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user