Compare commits
12 Commits
phase-7-fu
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e3c7206dd | |||
|
|
4e96f228b2 | ||
| 443474f58f | |||
|
|
dfe3731c73 | ||
| 6863cc4652 | |||
|
|
8221fac8c1 | ||
| bc44711dca | |||
|
|
acf31fd943 | ||
| 7e143e293b | |||
|
|
2cb22598d6 | ||
|
|
3d78033ea4 | ||
| 48a43ac96e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,3 +30,6 @@ packages/
|
||||
.claude/
|
||||
|
||||
.local/
|
||||
|
||||
# LiteDB local config cache (Phase 6.1 Stream D — runtime artifact, not source)
|
||||
src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db
|
||||
|
||||
64
src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Process-singleton registry of <see cref="IDriver"/> factories keyed by
|
||||
/// <c>DriverInstance.DriverType</c> string. Each driver project ships a DI
|
||||
/// extension (e.g. <c>services.AddGalaxyProxyDriverFactory()</c>) that registers
|
||||
/// its factory at startup; the bootstrapper looks up the factory by
|
||||
/// <c>DriverInstance.DriverType</c> + invokes it with the row's
|
||||
/// <c>DriverInstanceId</c> + <c>DriverConfig</c> JSON.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Closes the gap surfaced by task #240 live smoke — DriverInstance rows in
|
||||
/// the central config DB had no path to materialise as registered <see cref="IDriver"/>
|
||||
/// instances. The factory registry is the seam.
|
||||
/// </remarks>
|
||||
public sealed class DriverFactoryRegistry
|
||||
{
|
||||
private readonly Dictionary<string, Func<string, string, IDriver>> _factories
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Register a factory for <paramref name="driverType"/>. Throws if a factory is
|
||||
/// already registered for that type — drivers are singletons by type-name in
|
||||
/// this process.
|
||||
/// </summary>
|
||||
/// <param name="driverType">Matches <c>DriverInstance.DriverType</c>.</param>
|
||||
/// <param name="factory">
|
||||
/// Receives <c>(driverInstanceId, driverConfigJson)</c>; returns a new
|
||||
/// <see cref="IDriver"/>. Must NOT call <see cref="IDriver.InitializeAsync"/>
|
||||
/// itself — the bootstrapper calls it via <see cref="DriverHost.RegisterAsync"/>
|
||||
/// so the host's per-driver retry semantics apply uniformly.
|
||||
/// </param>
|
||||
public void Register(string driverType, Func<string, string, IDriver> factory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
lock (_lock)
|
||||
{
|
||||
if (_factories.ContainsKey(driverType))
|
||||
throw new InvalidOperationException(
|
||||
$"DriverType '{driverType}' factory already registered for this process");
|
||||
_factories[driverType] = factory;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to look up the factory for <paramref name="driverType"/>. Returns null
|
||||
/// if no driver assembly registered one — bootstrapper logs + skips so a
|
||||
/// missing-assembly deployment doesn't take down the whole server.
|
||||
/// </summary>
|
||||
public Func<string, string, IDriver>? TryGet(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
lock (_lock) return _factories.GetValueOrDefault(driverType);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> RegisteredTypes
|
||||
{
|
||||
get { lock (_lock) return [.. _factories.Keys]; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||
/// materialises FOCAS DriverInstance rows from the central config DB into live driver
|
||||
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>; no dependency on
|
||||
/// Microsoft.Extensions.DependencyInjection so the driver project stays DI-free.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>"Backend": "ipc"</c> (default) — wires <see cref="IpcFocasClientFactory"/>
|
||||
/// against a named-pipe <see cref="FocasIpcClient"/> talking to a separate
|
||||
/// <c>Driver.FOCAS.Host</c> process (Tier-C isolation). Requires <c>PipeName</c> +
|
||||
/// <c>SharedSecret</c>.</item>
|
||||
/// <item><c>"Backend": "fwlib"</c> — direct in-process Fwlib32.dll P/Invoke via
|
||||
/// <see cref="FwlibFocasClientFactory"/>. Use only when the main server is licensed
|
||||
/// for FOCAS and you accept the native-crash blast-radius trade-off.</item>
|
||||
/// <item><c>"Backend": "unimplemented"</c> — returns the no-op factory; useful for
|
||||
/// scaffolding DriverInstance rows before the Host is deployed so the server boots.</item>
|
||||
/// </list>
|
||||
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and feed directly
|
||||
/// into <see cref="FocasDriverOptions"/>.
|
||||
/// </remarks>
|
||||
public static class FocasDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "FOCAS";
|
||||
|
||||
/// <summary>
|
||||
/// Register the FOCAS driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
|
||||
/// Throws if 'FOCAS' is already registered — single-instance per process.
|
||||
/// </summary>
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<FocasDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
// Eager-validate top-level Series so a typo fails fast regardless of whether Devices
|
||||
// are populated yet (common during rollout when rows are seeded before CNCs arrive).
|
||||
_ = ParseSeries(dto.Series);
|
||||
|
||||
var options = new FocasDriverOptions
|
||||
{
|
||||
Devices = dto.Devices is { Count: > 0 }
|
||||
? [.. dto.Devices.Select(d => new FocasDeviceOptions(
|
||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
DeviceName: d.DeviceName,
|
||||
Series: ParseSeries(d.Series ?? dto.Series)))]
|
||||
: [],
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"FOCAS config for '{driverInstanceId}' has a tag missing Name"),
|
||||
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||
Address: t.Address ?? throw new InvalidOperationException(
|
||||
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||
DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
|
||||
Writable: t.Writable ?? true,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false))]
|
||||
: [],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||
},
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
};
|
||||
|
||||
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
||||
return new FocasDriver(options, driverInstanceId, clientFactory);
|
||||
}
|
||||
|
||||
internal static IFocasClientFactory BuildClientFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
var backend = (dto.Backend ?? "ipc").Trim().ToLowerInvariant();
|
||||
return backend switch
|
||||
{
|
||||
"ipc" => BuildIpcFactory(dto, driverInstanceId),
|
||||
"fwlib" or "fwlib32" => new FwlibFocasClientFactory(),
|
||||
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
|
||||
"Expected one of: ipc, fwlib, unimplemented."),
|
||||
};
|
||||
}
|
||||
|
||||
private static IpcFocasClientFactory BuildIpcFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
var pipeName = dto.PipeName
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' missing required PipeName (Tier-C ipc backend)");
|
||||
var sharedSecret = dto.SharedSecret
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' missing required SharedSecret (Tier-C ipc backend)");
|
||||
var connectTimeout = TimeSpan.FromMilliseconds(dto.ConnectTimeoutMs ?? 10_000);
|
||||
var series = ParseSeries(dto.Series);
|
||||
|
||||
// Each IFocasClientFactory.Create() call opens a fresh pipe to the Host — matches the
|
||||
// driver's one-client-per-device invariant. FocasIpcClient.ConnectAsync is awaited
|
||||
// synchronously via GetAwaiter().GetResult() because IFocasClientFactory.Create is a
|
||||
// sync contract; the blocking call lands inside FocasDriver.EnsureConnectedAsync,
|
||||
// which immediately awaits IFocasClient.ConnectAsync afterwards so the perceived
|
||||
// latency is identical to a fully-async factory.
|
||||
return new IpcFocasClientFactory(
|
||||
ipcClientFactory: () => FocasIpcClient.ConnectAsync(
|
||||
pipeName: pipeName,
|
||||
sharedSecret: sharedSecret,
|
||||
connectTimeout: connectTimeout,
|
||||
ct: CancellationToken.None).GetAwaiter().GetResult(),
|
||||
series: series);
|
||||
}
|
||||
|
||||
private static FocasCncSeries ParseSeries(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
|
||||
return Enum.TryParse<FocasCncSeries>(raw, ignoreCase: true, out var s)
|
||||
? s
|
||||
: throw new InvalidOperationException(
|
||||
$"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames<FocasCncSeries>())}");
|
||||
}
|
||||
|
||||
private static FocasDataType ParseDataType(string? raw, string tagName, string driverInstanceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
throw new InvalidOperationException(
|
||||
$"FOCAS tag '{tagName}' in '{driverInstanceId}' missing DataType");
|
||||
return Enum.TryParse<FocasDataType>(raw, ignoreCase: true, out var dt)
|
||||
? dt
|
||||
: throw new InvalidOperationException(
|
||||
$"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<FocasDataType>())}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
internal sealed class FocasDriverConfigDto
|
||||
{
|
||||
public string? Backend { get; init; }
|
||||
public string? PipeName { get; init; }
|
||||
public string? SharedSecret { get; init; }
|
||||
public int? ConnectTimeoutMs { get; init; }
|
||||
public string? Series { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
public List<FocasDeviceDto>? Devices { get; init; }
|
||||
public List<FocasTagDto>? Tags { get; init; }
|
||||
public FocasProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasDeviceDto
|
||||
{
|
||||
public string? HostAddress { get; init; }
|
||||
public string? DeviceName { get; init; }
|
||||
public string? Series { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasTagDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? DeviceHostAddress { get; init; }
|
||||
public string? Address { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public bool? Writable { get; init; }
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasProbeDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int? IntervalMs { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="GalaxyProxyDriver"/>. Server's
|
||||
/// Program.cs calls <see cref="Register"/> once at startup; the bootstrapper (task #248)
|
||||
/// then materialises Galaxy DriverInstance rows from the central config DB into live
|
||||
/// driver instances. No dependency on Microsoft.Extensions.DependencyInjection so the
|
||||
/// driver project stays free of DI machinery.
|
||||
/// </summary>
|
||||
public static class GalaxyProxyDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "Galaxy";
|
||||
|
||||
/// <summary>
|
||||
/// Register the Galaxy driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
|
||||
/// Throws if 'Galaxy' is already registered — single-instance per process.
|
||||
/// </summary>
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static GalaxyProxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
// DriverConfig column is a JSON object that mirrors GalaxyProxyOptions.
|
||||
// Required: PipeName, SharedSecret. Optional: ConnectTimeoutMs (defaults to 10s).
|
||||
// The DriverInstanceId from the row wins over any value in the JSON — the row
|
||||
// is the authoritative identity per the schema's UX_DriverInstance_Generation_LogicalId.
|
||||
using var doc = JsonDocument.Parse(driverConfigJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
string pipeName = root.TryGetProperty("PipeName", out var p) && p.ValueKind == JsonValueKind.String
|
||||
? p.GetString()!
|
||||
: throw new InvalidOperationException(
|
||||
$"GalaxyProxyDriver config for '{driverInstanceId}' missing required PipeName");
|
||||
string sharedSecret = root.TryGetProperty("SharedSecret", out var s) && s.ValueKind == JsonValueKind.String
|
||||
? s.GetString()!
|
||||
: throw new InvalidOperationException(
|
||||
$"GalaxyProxyDriver config for '{driverInstanceId}' missing required SharedSecret");
|
||||
var connectTimeout = root.TryGetProperty("ConnectTimeoutMs", out var t) && t.ValueKind == JsonValueKind.Number
|
||||
? TimeSpan.FromMilliseconds(t.GetInt32())
|
||||
: TimeSpan.FromSeconds(10);
|
||||
|
||||
return new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = driverInstanceId,
|
||||
PipeName = pipeName,
|
||||
SharedSecret = sharedSecret,
|
||||
ConnectTimeout = connectTimeout,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
88
src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs
Normal file
88
src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Task #248 — bridges the gap surfaced by the Phase 7 live smoke (#240) where
|
||||
/// <c>DriverInstance</c> rows in the central config DB had no path to materialise
|
||||
/// as live <see cref="Core.Abstractions.IDriver"/> instances in <see cref="DriverHost"/>.
|
||||
/// Called from <c>OpcUaServerService.ExecuteAsync</c> after the bootstrap loads
|
||||
/// the published generation, before address-space build.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per row: looks up the <c>DriverType</c> string in
|
||||
/// <see cref="DriverFactoryRegistry"/>, calls the factory with the row's
|
||||
/// <c>DriverInstanceId</c> + <c>DriverConfig</c> JSON to construct an
|
||||
/// <see cref="Core.Abstractions.IDriver"/>, then registers via
|
||||
/// <see cref="DriverHost.RegisterAsync"/> which invokes <c>InitializeAsync</c>
|
||||
/// under the host's lifecycle semantics.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Unknown <c>DriverType</c> = factory not registered = log a warning and skip.
|
||||
/// Per plan decision #12 (driver isolation), failure to construct or initialize
|
||||
/// one driver doesn't prevent the rest from coming up — the Server keeps serving
|
||||
/// the others' subtrees + the operator can fix the misconfigured row + republish
|
||||
/// to retry.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DriverInstanceBootstrapper(
|
||||
DriverFactoryRegistry factories,
|
||||
DriverHost driverHost,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<DriverInstanceBootstrapper> logger)
|
||||
{
|
||||
public async Task<int> RegisterDriversFromGenerationAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
|
||||
var rows = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.GenerationId == generationId && d.Enabled)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var registered = 0;
|
||||
var skippedUnknownType = 0;
|
||||
var failedInit = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var factory = factories.TryGet(row.DriverType);
|
||||
if (factory is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"DriverInstance {Id} skipped — DriverType '{Type}' has no registered factory (known: {Known})",
|
||||
row.DriverInstanceId, row.DriverType, string.Join(",", factories.RegisteredTypes));
|
||||
skippedUnknownType++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driver = factory(row.DriverInstanceId, row.DriverConfig);
|
||||
await driverHost.RegisterAsync(driver, row.DriverConfig, ct).ConfigureAwait(false);
|
||||
registered++;
|
||||
logger.LogInformation(
|
||||
"DriverInstance {Id} ({Type}) registered + initialized", row.DriverInstanceId, row.DriverType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Plan decision #12 — driver isolation. Log + continue so one bad row
|
||||
// doesn't deny the OPC UA endpoint to the rest of the fleet.
|
||||
logger.LogError(ex,
|
||||
"DriverInstance {Id} ({Type}) failed to initialize — driver state will reflect Faulted; operator can republish to retry",
|
||||
row.DriverInstanceId, row.DriverType);
|
||||
failedInit++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"DriverInstanceBootstrapper: gen={Gen} registered={Registered} skippedUnknownType={Skipped} failedInit={Failed}",
|
||||
generationId, registered, skippedUnknownType, failedInit);
|
||||
return registered;
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
|
||||
DisplayName = new LocalizedText(info.SourceName),
|
||||
};
|
||||
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, false);
|
||||
// assignNodeIds=true makes the stack allocate NodeIds for every inherited
|
||||
// AlarmConditionState child (Severity / Message / ActiveState / AckedState /
|
||||
// EnabledState / …). Without this the children keep Foundation (ns=0) type-
|
||||
// declaration NodeIds that aren't in the node manager's predefined-node index.
|
||||
// The newly-allocated NodeIds default to ns=0 via the shared identifier
|
||||
// counter — we remap them to the node manager's namespace below so client
|
||||
// Read/Browse on children resolves against the predefined-node dictionary.
|
||||
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, true);
|
||||
// Assign every descendant a stable, collision-free NodeId in the node manager's
|
||||
// namespace keyed on the condition path. The stack's default assignNodeIds path
|
||||
// allocates from a shared ns=0 counter and does not update parent→child
|
||||
// references when we remap, so we do the rename up front, symbolically:
|
||||
// {condition-full-ref}/{symbolic-path-under-condition}
|
||||
AssignSymbolicDescendantIds(alarm, alarm.NodeId, _owner.NamespaceIndex);
|
||||
alarm.SourceName.Value = info.SourceName;
|
||||
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
|
||||
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
|
||||
@@ -382,10 +395,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
alarm.AckedState.Id.Value = true;
|
||||
alarm.ActiveState.Value = new LocalizedText("Inactive");
|
||||
alarm.ActiveState.Id.Value = false;
|
||||
// Enable ConditionRefresh support so clients that connect *after* a transition
|
||||
// can pull the current retained-condition snapshot.
|
||||
alarm.ClientUserId.Value = string.Empty;
|
||||
alarm.BranchId.Value = NodeId.Null;
|
||||
|
||||
_variable.AddChild(alarm);
|
||||
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
|
||||
|
||||
// Part 9 event propagation: AddRootNotifier registers the alarm as an event
|
||||
// source reachable from Objects/Server so subscriptions placed on Server-object
|
||||
// EventNotifier receive the ReportEvent calls ConditionSink.OnTransition emits.
|
||||
// Without this the Report fires but has no subscribers to deliver to.
|
||||
_owner.AddRootNotifier(alarm);
|
||||
|
||||
return new ConditionSink(_owner, alarm);
|
||||
}
|
||||
}
|
||||
@@ -398,6 +421,26 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
AlarmSeverity.Critical => 900,
|
||||
_ => 500,
|
||||
};
|
||||
|
||||
// After alarm.Create(assignNodeIds=true), every descendant has *some* NodeId but
|
||||
// they default to ns=0 via the shared identifier counter — allocations from two
|
||||
// different alarms collide when we move them into the driver's namespace. Rewriting
|
||||
// symbolically based on the condition path gives each descendant a unique, stable
|
||||
// NodeId in the node manager's namespace. Browse + Read resolve against the current
|
||||
// NodeId because the stack's CustomNodeManager2.Browse traverses NodeState.Children
|
||||
// (NodeState references) and uses each child's current .NodeId in the response.
|
||||
private static void AssignSymbolicDescendantIds(
|
||||
NodeState parent, NodeId parentNodeId, ushort namespaceIndex)
|
||||
{
|
||||
var children = new List<BaseInstanceState>();
|
||||
parent.GetChildren(null!, children);
|
||||
foreach (var child in children)
|
||||
{
|
||||
child.NodeId = new NodeId(
|
||||
$"{parentNodeId.Identifier}.{child.SymbolicName}", namespaceIndex);
|
||||
AssignSymbolicDescendantIds(child, child.NodeId, namespaceIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class OpcUaServerService(
|
||||
DriverHost driverHost,
|
||||
OpcUaApplicationHost applicationHost,
|
||||
DriverEquipmentContentRegistry equipmentContentRegistry,
|
||||
DriverInstanceBootstrapper driverBootstrapper,
|
||||
Phase7Composer phase7Composer,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<OpcUaServerService> logger) : BackgroundService
|
||||
@@ -37,6 +38,13 @@ public sealed class OpcUaServerService(
|
||||
// address space until the first publish, then the registry fills on next restart.
|
||||
if (result.GenerationId is { } gen)
|
||||
{
|
||||
// Task #248 — register IDriver instances from the published DriverInstance
|
||||
// rows BEFORE the equipment-content load + Phase 7 compose, so the rest of
|
||||
// the pipeline sees a populated DriverHost. Without this step Phase 7's
|
||||
// CachedTagUpstreamSource has no upstream feed + virtual-tag scripts read
|
||||
// BadNodeIdUnknown for every tag path (gap surfaced by task #240 smoke).
|
||||
await driverBootstrapper.RegisterDriversFromGenerationAsync(gen, stoppingToken);
|
||||
|
||||
await PopulateEquipmentContentAsync(gen, stoppingToken);
|
||||
|
||||
// Phase 7 follow-up #246 — load Script + VirtualTag + ScriptedAlarm rows,
|
||||
|
||||
@@ -9,6 +9,8 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
@@ -89,6 +91,19 @@ builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(opti
|
||||
builder.Services.AddSingleton<DriverHost>();
|
||||
builder.Services.AddSingleton<NodeBootstrap>();
|
||||
|
||||
// Task #248 — driver-instance bootstrap pipeline. DriverFactoryRegistry is the
|
||||
// type-name → factory map; each driver project's static Register call pre-loads
|
||||
// its factory so the bootstrapper can materialise DriverInstance rows from the
|
||||
// central DB into live IDriver instances.
|
||||
builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
GalaxyProxyDriverFactoryExtensions.Register(registry);
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
return registry;
|
||||
});
|
||||
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
||||
|
||||
// ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's
|
||||
// bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation.
|
||||
// DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
<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.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -32,13 +33,36 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
|
||||
public long SeededGenerationId { get; private set; }
|
||||
public string SeededClusterId { get; } = "e2e-cluster";
|
||||
|
||||
/// <summary>
|
||||
/// Root service provider of the running host. Tests use this to create scopes that
|
||||
/// share the InMemory DB with the Blazor-rendered page — e.g. to assert post-commit
|
||||
/// state, or to simulate a concurrent peer edit that bumps the DraftRevisionToken
|
||||
/// between preview-open and Confirm-click.
|
||||
/// </summary>
|
||||
public IServiceProvider Services => _app?.Services
|
||||
?? throw new InvalidOperationException("AdminWebAppFactory: StartAsync has not been called");
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var port = GetFreeTcpPort();
|
||||
BaseUrl = $"http://127.0.0.1:{port}";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(Array.Empty<string>());
|
||||
// Point the content root at the Admin project's build output so wwwroot/ (app.css,
|
||||
// site CSS, icons) + the Blazor framework assets served from the Admin assembly
|
||||
// resolve. Without this the default content root is the test project's bin dir and
|
||||
// blazor.web.js + app.css return 404, which keeps the interactive circuit from
|
||||
// booting at all.
|
||||
var adminAssemblyDir = System.IO.Path.GetDirectoryName(
|
||||
typeof(Admin.Components.App).Assembly.Location)!;
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
ContentRootPath = adminAssemblyDir,
|
||||
});
|
||||
builder.WebHost.UseUrls(BaseUrl);
|
||||
// E2E host runs in Development so unhandled exceptions during Blazor render surface
|
||||
// as visible 500s with stacks the test can capture — prod-style generic errors make
|
||||
// diagnosis of circuit / DI misconfig effectively impossible.
|
||||
builder.Environment.EnvironmentName = Microsoft.Extensions.Hosting.Environments.Development;
|
||||
|
||||
// --- Mirror the Admin composition in Program.cs, but with the InMemory DB + test
|
||||
// auth swaps instead of SQL Server + LDAP cookie auth.
|
||||
@@ -72,6 +96,12 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
|
||||
_app.UseAuthorization();
|
||||
_app.UseAntiforgery();
|
||||
_app.MapRazorComponents<Admin.Components.App>().AddInteractiveServerRenderMode();
|
||||
// The ClusterDetail + other pages connect SignalR hubs at render time — the
|
||||
// endpoints must exist or the Blazor circuit surfaces a 500 on first interactive
|
||||
// step. No background pollers (FleetStatusPoller etc.) are registered so the hubs
|
||||
// stay quiet until something pushes through IHubContext, which the E2E tests don't.
|
||||
_app.MapHub<FleetStatusHub>("/hubs/fleet");
|
||||
_app.MapHub<AlertHub>("/hubs/alerts");
|
||||
|
||||
// Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav.
|
||||
using (var scope = _app.Services.CreateScope())
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Playwright;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
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.
|
||||
/// Phase 6.4 UnsTab drag-drop E2E. Task #199 landed the scaffolding; task #242 drives the
|
||||
/// Blazor Server interactive circuit through a real drag-drop → confirm-modal → apply flow
|
||||
/// and a 409 concurrent-edit flow. Both interactive tests are currently
|
||||
/// <see cref="FactAttribute.Skip"/>-guarded — see below.
|
||||
/// </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.
|
||||
/// <b>Current blocker (both interactive tests skipped).</b> The Blazor Server circuit
|
||||
/// never boots in the test-owned pipeline because <c>_framework/blazor.web.js</c>
|
||||
/// returns HTTP 200 with a zero-byte body. The asset's route is declared in the Admin
|
||||
/// project's <c>OtOpcUa.Admin.staticwebassets.endpoints.json</c> manifest, but the
|
||||
/// underlying file is shipped via the framework NuGet
|
||||
/// (<c>Microsoft.AspNetCore.App.Internal.Assets/_framework/blazor.web.js</c>) rather
|
||||
/// than the Admin's <c>wwwroot</c>. <see cref="AdminWebAppFactory"/> points the content
|
||||
/// root at the Admin assembly directory + maps hubs + runs in Development, so routing
|
||||
/// / auth / DbContext / hub negotiation all succeed — the only gap is wiring the
|
||||
/// framework-asset file provider into <c>MapStaticAssets</c> or <c>UseStaticFiles</c>.
|
||||
/// The drag-drop + 409 scenarios are fully written; un-skipping them is a matter of
|
||||
/// plumbing, not rewriting the test logic.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Options for closing the gap.</b> (a) Layer a composite file provider that maps
|
||||
/// <c>/_framework/*</c> into the NuGet cache at test-init time. (b) Launch the real
|
||||
/// <c>dotnet run --project Admin</c> process as a subprocess with an InMemory DB
|
||||
/// override — closest to the production composition. (c) Copy the framework asset
|
||||
/// files into the test project's output via MSBuild so <c>UseStaticFiles</c> with
|
||||
/// <c>ContentRootPath</c>=Admin bin finds them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "E2E")]
|
||||
@@ -35,34 +50,20 @@ public sealed class UnsTabDragDropE2ETests
|
||||
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;
|
||||
}
|
||||
var fixture = await TryInitPlaywrightAsync();
|
||||
if (fixture is null) 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");
|
||||
}
|
||||
@@ -71,4 +72,150 @@ public sealed class UnsTabDragDropE2ETests
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "Task #242 blocked on blazor.web.js asset resolution — see class docstring. " +
|
||||
"Test body is complete + validated against the scaffolding; un-skip once the framework " +
|
||||
"file provider is wired into AdminWebAppFactory.")]
|
||||
public async Task Dragging_line_onto_new_area_shows_preview_modal_then_confirms_the_move()
|
||||
{
|
||||
await using var app = new AdminWebAppFactory();
|
||||
await app.StartAsync();
|
||||
|
||||
var fixture = await TryInitPlaywrightAsync();
|
||||
if (fixture is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var ctx = await fixture.Browser.NewContextAsync();
|
||||
var page = await ctx.NewPageAsync();
|
||||
|
||||
await OpenUnsTabAsync(page, app);
|
||||
|
||||
// The seed wires line 'oven-line' to area 'warsaw' (area-a); dragging it onto
|
||||
// 'berlin' (area-b) should surface the preview modal. Playwright's DragToAsync
|
||||
// dispatches native dragstart / dragover / drop events that the razor's
|
||||
// @ondragstart / @ondragover / @ondrop handlers pick up.
|
||||
var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" });
|
||||
var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" });
|
||||
await lineRow.DragToAsync(berlinRow);
|
||||
|
||||
var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" });
|
||||
await modalTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||
|
||||
var modalBody = await page.Locator(".modal-body").InnerTextAsync();
|
||||
modalBody.ShouldContain("Equipment re-homed",
|
||||
customMessage: "preview modal should render UnsImpactAnalyzer summary");
|
||||
|
||||
await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" })
|
||||
.ClickAsync();
|
||||
|
||||
// Modal dismisses after the MoveLineAsync round-trip + ReloadAsync.
|
||||
await modalTitle.WaitForAsync(new() { State = WaitForSelectorState.Detached, Timeout = 10_000 });
|
||||
|
||||
// Persisted state: the line row now shows area-b as its Area column value.
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var line = await db.UnsLines.AsNoTracking()
|
||||
.FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId);
|
||||
line.UnsAreaId.ShouldBe("area-b",
|
||||
"drag-drop should have moved the line to the berlin area via UnsService.MoveLineAsync");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "Task #242 blocked on blazor.web.js asset resolution — see class docstring.")]
|
||||
public async Task Preview_shown_then_peer_edit_applied_surfaces_409_conflict_modal()
|
||||
{
|
||||
await using var app = new AdminWebAppFactory();
|
||||
await app.StartAsync();
|
||||
|
||||
var fixture = await TryInitPlaywrightAsync();
|
||||
if (fixture is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var ctx = await fixture.Browser.NewContextAsync();
|
||||
var page = await ctx.NewPageAsync();
|
||||
|
||||
await OpenUnsTabAsync(page, app);
|
||||
|
||||
// Open the preview first (same drag as the happy-path test). The preview captures
|
||||
// a RevisionToken under the current draft state.
|
||||
var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" });
|
||||
var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" });
|
||||
await lineRow.DragToAsync(berlinRow);
|
||||
|
||||
var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" });
|
||||
await modalTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||
|
||||
// Simulate a concurrent operator committing their own edit between the preview
|
||||
// open + our Confirm click — bumps the DraftRevisionToken so our stale token hits
|
||||
// DraftRevisionConflictException in UnsService.MoveLineAsync.
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var uns = scope.ServiceProvider.GetRequiredService<Admin.Services.UnsService>();
|
||||
await uns.AddAreaAsync(app.SeededGenerationId, app.SeededClusterId,
|
||||
"madrid", notes: null, CancellationToken.None);
|
||||
}
|
||||
|
||||
await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" })
|
||||
.ClickAsync();
|
||||
|
||||
var conflictTitle = page.Locator(".modal-title",
|
||||
new() { HasTextString = "Draft changed" });
|
||||
await conflictTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||
|
||||
// Persisted state: line still points at the original area-a — the conflict short-
|
||||
// circuited the move.
|
||||
using var verifyScope = app.Services.CreateScope();
|
||||
var db = verifyScope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var line = await db.UnsLines.AsNoTracking()
|
||||
.FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId);
|
||||
line.UnsAreaId.ShouldBe("area-a",
|
||||
"conflict path must not overwrite the peer operator's draft state");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<PlaywrightFixture?> TryInitPlaywrightAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var fixture = new PlaywrightFixture();
|
||||
await fixture.InitializeAsync();
|
||||
return fixture;
|
||||
}
|
||||
catch (PlaywrightBrowserMissingException)
|
||||
{
|
||||
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the seeded cluster and switches to the UNS Structure tab, waiting for
|
||||
/// the Blazor Server interactive circuit to render the draggable line table. Returns
|
||||
/// once the drop-target cells ("drop here") are visible — that's the signal the
|
||||
/// circuit is live and @ondragstart handlers are wired.
|
||||
/// </summary>
|
||||
private static async Task OpenUnsTabAsync(IPage page, AdminWebAppFactory app)
|
||||
{
|
||||
await page.GotoAsync($"{app.BaseUrl}/clusters/{app.SeededClusterId}",
|
||||
new() { WaitUntil = WaitUntilState.NetworkIdle, Timeout = 20_000 });
|
||||
|
||||
var unsTab = page.Locator("button.nav-link", new() { HasTextString = "UNS Structure" });
|
||||
await unsTab.WaitForAsync(new() { Timeout = 15_000 });
|
||||
await unsTab.ClickAsync();
|
||||
|
||||
// "drop here" is the per-area hint cell — only rendered inside <UnsTab> so its
|
||||
// visibility confirms both the tab switch and the circuit's interactive render.
|
||||
await page.Locator("td", new() { HasTextString = "drop here" })
|
||||
.First.WaitForAsync(new() { Timeout = 15_000 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #220 — covers the DriverConfig JSON contract that
|
||||
/// <see cref="FocasDriverFactoryExtensions.CreateInstance"/> parses when the bootstrap
|
||||
/// pipeline (task #248) materialises FOCAS DriverInstance rows. Pure unit tests, no pipe
|
||||
/// or CNC required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasDriverFactoryExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Register_adds_FOCAS_entry_to_registry()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
registry.TryGet("FOCAS").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_is_case_insensitive_via_registry()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
registry.TryGet("focas").ShouldNotBeNull();
|
||||
registry.TryGet("Focas").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_with_ipc_backend_and_valid_config_returns_FocasDriver()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "ipc",
|
||||
"PipeName": "OtOpcUaFocasHost",
|
||||
"SharedSecret": "secret-for-test",
|
||||
"ConnectTimeoutMs": 5000,
|
||||
"Series": "Thirty_i",
|
||||
"TimeoutMs": 3000,
|
||||
"Devices": [
|
||||
{ "HostAddress": "focas://10.0.0.5:8193", "DeviceName": "Lathe1" }
|
||||
],
|
||||
"Tags": [
|
||||
{ "Name": "Override", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "R100", "DataType": "Int32", "Writable": true }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-0", json);
|
||||
|
||||
driver.ShouldNotBeNull();
|
||||
driver.DriverInstanceId.ShouldBe("focas-0");
|
||||
driver.DriverType.ShouldBe("FOCAS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_defaults_Backend_to_ipc_when_unspecified()
|
||||
{
|
||||
// No "Backend" key → defaults to ipc → requires PipeName + SharedSecret.
|
||||
const string json = """
|
||||
{ "PipeName": "p", "SharedSecret": "s" }
|
||||
""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-default", json);
|
||||
driver.DriverType.ShouldBe("FOCAS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_ipc_backend_missing_PipeName_throws()
|
||||
{
|
||||
const string json = """{ "Backend": "ipc", "SharedSecret": "s" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-pipe", json))
|
||||
.Message.ShouldContain("PipeName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_ipc_backend_missing_SharedSecret_throws()
|
||||
{
|
||||
const string json = """{ "Backend": "ipc", "PipeName": "p" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-secret", json))
|
||||
.Message.ShouldContain("SharedSecret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_fwlib_backend_does_not_require_pipe_fields()
|
||||
{
|
||||
// Direct in-process Fwlib32 path. No pipe config needed; driver connects the DLL
|
||||
// natively on first use.
|
||||
const string json = """{ "Backend": "fwlib" }""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-fwlib", json);
|
||||
driver.DriverInstanceId.ShouldBe("focas-fwlib");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_unimplemented_backend_yields_driver_that_fails_fast_on_use()
|
||||
{
|
||||
// Useful for staging DriverInstance rows in the config DB before the Host is
|
||||
// actually deployed — the server boots but reads/writes surface clear errors.
|
||||
const string json = """{ "Backend": "unimplemented" }""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-unimpl", json);
|
||||
driver.DriverInstanceId.ShouldBe("focas-unimpl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_unknown_backend_throws_with_expected_list()
|
||||
{
|
||||
const string json = """{ "Backend": "gibberish", "PipeName": "p", "SharedSecret": "s" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-backend", json))
|
||||
.Message.ShouldContain("gibberish");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_rejects_unknown_Series()
|
||||
{
|
||||
const string json = """
|
||||
{ "Backend": "fwlib", "Series": "NotARealSeries" }
|
||||
""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-series", json))
|
||||
.Message.ShouldContain("NotARealSeries");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_rejects_tag_with_missing_DataType()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "fwlib",
|
||||
"Devices": [{ "HostAddress": "focas://1.1.1.1:8193" }],
|
||||
"Tags": [{ "Name": "Broken", "DeviceHostAddress": "focas://1.1.1.1:8193", "Address": "R1" }]
|
||||
}
|
||||
""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-tag", json))
|
||||
.Message.ShouldContain("DataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_null_or_whitespace_args_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("", "{}"));
|
||||
Should.Throw<ArgumentException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("id", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_twice_throws()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.Register(registry));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #219 — end-to-end server integration coverage for the <see cref="IAlarmSource"/>
|
||||
/// dispatch path. Boots the full OPC UA stack + a fake <see cref="IAlarmSource"/> driver,
|
||||
/// opens a client session, raises a driver-side transition, and asserts it propagates
|
||||
/// through <c>GenericDriverNodeManager</c>'s alarm forwarder into
|
||||
/// <c>DriverNodeManager.ConditionSink</c>, updates the server-side
|
||||
/// <c>AlarmConditionState</c> child attributes (Severity / Message / ActiveState), and
|
||||
/// flows out to an OPC UA subscription on the Server object's EventNotifier.
|
||||
///
|
||||
/// Companion to <see cref="HistoryReadIntegrationTests"/> which covers the
|
||||
/// <see cref="IHistoryProvider"/> dispatch path; together they close the server-side
|
||||
/// integration gap for optional driver capabilities (plan decision #62).
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class AlarmSubscribeIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly int Port = 48700 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaAlarmTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-test-{Guid.NewGuid():N}");
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
private AlarmDriver _driver = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
_driver = new AlarmDriver();
|
||||
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaAlarmTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:AlarmTest",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
HealthEndpointsEnabled = false,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_alarm_transition_updates_server_side_AlarmConditionState_node()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
|
||||
|
||||
_driver.RaiseAlarm(new AlarmEventArgs(
|
||||
SubscriptionHandle: new FakeHandle("sub"),
|
||||
SourceNodeId: "Tank.HiHi",
|
||||
ConditionId: "cond-1",
|
||||
AlarmType: "Active",
|
||||
Message: "Level exceeded upper-upper",
|
||||
Severity: AlarmSeverity.High,
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
|
||||
// The alarm-condition node's identifier is the driver full-reference + ".Condition"
|
||||
// (DriverNodeManager.VariableHandle.MarkAsAlarmCondition). Server-side state changes
|
||||
// are applied synchronously under DriverNodeManager.Lock inside ConditionSink.OnTransition,
|
||||
// so by the time RaiseAlarm returns the node state has been flushed.
|
||||
var conditionNodeId = new NodeId("Tank.HiHi.Condition", nsIndex);
|
||||
|
||||
// Browse the condition node for the well-known Part-9 child variables. The stack
|
||||
// materializes Severity / Message / ActiveState / AckedState as children below the
|
||||
// AlarmConditionState; their NodeIds are allocated by the stack so we discover them
|
||||
// by BrowseName rather than guessing.
|
||||
var browseDescriptions = new BrowseDescriptionCollection
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = conditionNodeId,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = 0,
|
||||
ResultMask = (uint)BrowseResultMask.All,
|
||||
},
|
||||
};
|
||||
session.Browse(null, null, 0, browseDescriptions, out var browseResults, out _);
|
||||
var children = browseResults[0].References
|
||||
.ToDictionary(r => r.BrowseName.Name,
|
||||
r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
children.ShouldContainKey("Severity");
|
||||
children.ShouldContainKey("Message");
|
||||
children.ShouldContainKey("ActiveState");
|
||||
|
||||
// Severity / Message / ActiveState.Id reflect the driver-fired transition — verifies
|
||||
// the forwarder → ConditionSink.OnTransition → alarm.ClearChangeMasks pipeline
|
||||
// landed the new values in addressable child nodes. DriverNodeManager's
|
||||
// AssignSymbolicDescendantIds keeps each child reachable under the node manager's
|
||||
// namespace so Read resolves against the predefined-node dictionary.
|
||||
var severity = session.ReadValue(children["Severity"]);
|
||||
var message = session.ReadValue(children["Message"]);
|
||||
severity.Value.ShouldBe((ushort)700); // AlarmSeverity.High → 700 (MapSeverity)
|
||||
((LocalizedText)message.Value).Text.ShouldBe("Level exceeded upper-upper");
|
||||
|
||||
// ActiveState exposes its boolean Id as a HasProperty child.
|
||||
var activeBrowse = new BrowseDescriptionCollection
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = children["ActiveState"],
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HasProperty,
|
||||
IncludeSubtypes = true,
|
||||
ResultMask = (uint)BrowseResultMask.All,
|
||||
},
|
||||
};
|
||||
session.Browse(null, null, 0, activeBrowse, out var activeChildren, out _);
|
||||
var idRef = activeChildren[0].References.Single(r => r.BrowseName.Name == "Id");
|
||||
var activeId = session.ReadValue(ExpandedNodeId.ToNodeId(idRef.NodeId, session.NamespaceUris));
|
||||
activeId.Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier()
|
||||
{
|
||||
// AddRootNotifier registers the AlarmConditionState as a Server-object notifier
|
||||
// source, so a subscription with an EventFilter on Server receives the
|
||||
// ReportEvent calls ConditionSink emits per-transition.
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync();
|
||||
|
||||
var received = new List<EventFieldList>();
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
||||
filter.WhereClause = new ContentFilter();
|
||||
filter.WhereClause.Push(FilterOperator.OfType,
|
||||
new LiteralOperand { Value = new Variant(ObjectTypeIds.AlarmConditionType) });
|
||||
|
||||
var item = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = ObjectIds.Server,
|
||||
AttributeId = Attributes.EventNotifier,
|
||||
NodeClass = NodeClass.Object,
|
||||
SamplingInterval = 0,
|
||||
QueueSize = 100,
|
||||
Filter = filter,
|
||||
};
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is EventFieldList fields)
|
||||
{
|
||||
lock (received) { received.Add(fields); gate.TrySetResult(); }
|
||||
}
|
||||
};
|
||||
subscription.AddItem(item);
|
||||
await subscription.ApplyChangesAsync();
|
||||
|
||||
// Give the publish loop a tick to establish before firing.
|
||||
await Task.Delay(200);
|
||||
|
||||
_driver.RaiseAlarm(new AlarmEventArgs(
|
||||
new FakeHandle("sub"), "Tank.HiHi", "cond-x", "Active",
|
||||
"High-high tripped", AlarmSeverity.Critical, DateTime.UtcNow));
|
||||
|
||||
var delivered = await Task.WhenAny(gate.Task, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||
delivered.ShouldBe(gate.Task, "alarm event must arrive at the client within 10s");
|
||||
|
||||
EventFieldList first;
|
||||
lock (received) first = received[0];
|
||||
// Filter field order: 0=EventId, 1=SourceName, 2=Message, 3=Severity.
|
||||
((LocalizedText)first.EventFields[2].Value).Text.ShouldBe("High-high tripped");
|
||||
first.EventFields[3].Value.ShouldBe((ushort)900); // Critical → 900
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace()
|
||||
{
|
||||
// Tag-scoped alarm wiring: DiscoverAsync declares two IsAlarm variables and calls
|
||||
// MarkAsAlarmCondition on each. The server-side DriverNodeManager wraps each call in
|
||||
// a CapturingHandle that creates a sibling AlarmConditionState + registers a sink
|
||||
// under the driver full-reference. Browse should show both condition nodes with
|
||||
// distinct NodeIds using the FullReference + ".Condition" convention.
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
|
||||
|
||||
_driver.RaiseAlarm(new AlarmEventArgs(
|
||||
new FakeHandle("sub"), "Tank.HiHi", "c", "Active", "first", AlarmSeverity.High,
|
||||
DateTime.UtcNow));
|
||||
|
||||
var attrs = new ReadValueIdCollection
|
||||
{
|
||||
new() { NodeId = new NodeId("Tank.HiHi.Condition", nsIndex), AttributeId = Attributes.DisplayName },
|
||||
new() { NodeId = new NodeId("Heater.OverTemp.Condition", nsIndex), AttributeId = Attributes.DisplayName },
|
||||
};
|
||||
session.Read(null, 0, TimestampsToReturn.Neither, attrs, out var results, out _);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
((LocalizedText)results[0].Value).Text.ShouldBe("Tank.HiHi");
|
||||
((LocalizedText)results[1].Value).Text.ShouldBe("Heater.OverTemp");
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaAlarmTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:AlarmTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaAlarmTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaAlarmTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub <see cref="IAlarmSource"/> driver. <see cref="DiscoverAsync"/> emits two alarm-
|
||||
/// bearing variables (so tag-scoped fan-out can be asserted); <see cref="RaiseAlarm"/>
|
||||
/// fires <see cref="OnAlarmEvent"/> exactly like a real driver would.
|
||||
/// </summary>
|
||||
private sealed class AlarmDriver : IDriver, ITagDiscovery, IAlarmSource
|
||||
{
|
||||
public string DriverInstanceId => "alarm-driver";
|
||||
public string DriverType => "AlarmStub";
|
||||
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
var tank = builder.Folder("Tank", "Tank");
|
||||
var hiHi = tank.Variable("HiHi", "HiHi", new DriverAttributeInfo(
|
||||
"Tank.HiHi", DriverDataType.Boolean, false, null,
|
||||
SecurityClassification.FreeAccess, false, IsAlarm: true));
|
||||
hiHi.MarkAsAlarmCondition(new AlarmConditionInfo(
|
||||
"Tank.HiHi", AlarmSeverity.High, "High-high alarm"));
|
||||
|
||||
var heater = builder.Folder("Heater", "Heater");
|
||||
var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo(
|
||||
"Heater.OverTemp", DriverDataType.Boolean, false, null,
|
||||
SecurityClassification.FreeAccess, false, IsAlarm: true));
|
||||
ot.MarkAsAlarmCondition(new AlarmConditionInfo(
|
||||
"Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature"));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> _, CancellationToken __)
|
||||
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId { get; } = diagnosticId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #248 — covers the <see cref="DriverFactoryRegistry"/> contract that
|
||||
/// <see cref="DriverInstanceBootstrapper"/> consumes.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverFactoryRegistryTests
|
||||
{
|
||||
private static IDriver FakeDriver(string id, string config) => new FakeIDriver(id);
|
||||
|
||||
[Fact]
|
||||
public void Register_then_TryGet_returns_factory()
|
||||
{
|
||||
var r = new DriverFactoryRegistry();
|
||||
r.Register("MyDriver", FakeDriver);
|
||||
|
||||
r.TryGet("MyDriver").ShouldNotBeNull();
|
||||
r.TryGet("Nope").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_is_case_insensitive()
|
||||
{
|
||||
var r = new DriverFactoryRegistry();
|
||||
r.Register("Galaxy", FakeDriver);
|
||||
r.TryGet("galaxy").ShouldNotBeNull();
|
||||
r.TryGet("GALAXY").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_duplicate_type_throws()
|
||||
{
|
||||
var r = new DriverFactoryRegistry();
|
||||
r.Register("Galaxy", FakeDriver);
|
||||
Should.Throw<InvalidOperationException>(() => r.Register("Galaxy", FakeDriver));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_null_args_rejected()
|
||||
{
|
||||
var r = new DriverFactoryRegistry();
|
||||
Should.Throw<ArgumentException>(() => r.Register("", FakeDriver));
|
||||
Should.Throw<ArgumentNullException>(() => r.Register("X", null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredTypes_returns_snapshot()
|
||||
{
|
||||
var r = new DriverFactoryRegistry();
|
||||
r.Register("A", FakeDriver);
|
||||
r.Register("B", FakeDriver);
|
||||
r.RegisteredTypes.ShouldContain("A");
|
||||
r.RegisteredTypes.ShouldContain("B");
|
||||
}
|
||||
|
||||
private sealed class FakeIDriver(string id) : IDriver
|
||||
{
|
||||
public string DriverInstanceId => id;
|
||||
public string DriverType => "Fake";
|
||||
public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user