Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs
Joseph Doherty a25593a9c6 chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:55:28 -04:00

193 lines
8.4 KiB
C#

using System.Net.Sockets;
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests;
/// <summary>
/// Fixture for the focas-mock simulator. Probes the Docker mock at
/// collection init; if reachable, exposes helpers that drive the mock's
/// admin surface (<c>mock_load_profile</c>, <c>mock_patch</c>,
/// <c>mock_reset</c>, <c>mock_schedule_alarms</c>) so tests can seed
/// deterministic state before exercising the managed driver.
/// </summary>
/// <remarks>
/// Single skip gate: <see cref="SkipReason"/> is non-null when the
/// <c>localhost:8193</c> TCP probe fails. Tests call
/// <c>Assert.Skip</c>.
/// </remarks>
public sealed class FocasSimFixture : IAsyncDisposable
{
private const string EndpointEnvVar = "OTOPCUA_FOCAS_SIM_ENDPOINT";
private const string ProfileEnvVar = "OTOPCUA_FOCAS_SIM_PROFILE";
private const string DefaultHost = "localhost";
private const int DefaultPort = 8193;
public string Host { get; }
public int Port { get; }
/// <summary>focas-mock profile stem the fixture should load (e.g. <c>fwlib30i64</c>,
/// <c>ThirtyOne_i</c> — both resolve via the mock's alias table). Null when unset.</summary>
public string? ExpectedProfile { get; }
/// <summary>When the <see cref="ExpectedProfile"/> maps to a concrete
/// <see cref="FocasCncSeries"/>, this is it. Null otherwise.</summary>
public FocasCncSeries? ExpectedSeries { get; }
/// <summary>Non-null when the mock probe failed — tests skip with this reason.</summary>
public string? SkipReason { get; }
public FocasSimFixture()
{
var endpoint = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? $"{DefaultHost}:{DefaultPort}";
(Host, Port) = ParseEndpoint(endpoint);
ExpectedProfile = Environment.GetEnvironmentVariable(ProfileEnvVar);
ExpectedSeries = ParseSeries(ExpectedProfile);
try
{
using var client = new TcpClient(AddressFamily.InterNetwork);
var addresses = System.Net.Dns.GetHostAddresses(Host);
var ip = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback;
var task = client.ConnectAsync(ip, Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"focas-mock at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start it (`docker compose -f Docker/docker-compose.yml up -d`) " +
$"or override {EndpointEnvVar}.";
}
}
catch (Exception ex)
{
SkipReason = $"focas-mock at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start it or override {EndpointEnvVar}.";
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
// ---- Admin API helpers ----
/// <summary>
/// Load a focas-mock profile. Accepts either the raw DLL-stem name
/// (<c>fwlib30i64</c>) or the OtOpcUa-style alias (<c>ThirtyOne_i</c>);
/// focas-mock's <c>PROFILE_ALIASES</c> resolves both.
/// </summary>
public Task<JsonElement> LoadProfileAsync(string profileName, CancellationToken ct = default) =>
SendAdminAsync("mock_load_profile", new { profile = profileName }, ct);
/// <summary>Deep-merge <paramref name="state"/> into the mock's current state.</summary>
public Task<JsonElement> PatchStateAsync(object state, CancellationToken ct = default) =>
SendAdminAsync("mock_patch", new { state }, ct);
/// <summary>Reset the mock to the selected profile's default state.</summary>
public Task<JsonElement> ResetAsync(CancellationToken ct = default) =>
SendAdminAsync("mock_reset", new { }, ct);
/// <summary>Install a time-scheduled alarm raise / clear sequence.</summary>
public Task<JsonElement> ScheduleAlarmsAsync(IEnumerable<object> sequence, CancellationToken ct = default) =>
SendAdminAsync("mock_schedule_alarms", new { sequence }, ct);
/// <summary>Low-level JSON round-trip. One TCP connection per call — matches
/// how the shim talks to the mock; simpler than pooling.</summary>
public async Task<JsonElement> SendAdminAsync(string method, object @params, CancellationToken ct = default)
{
using var client = new TcpClient();
await client.ConnectAsync(Host, Port, ct).ConfigureAwait(false);
using var stream = client.GetStream();
var request = JsonSerializer.SerializeToUtf8Bytes(new
{
id = Interlocked.Increment(ref _nextId),
method,
@params,
});
await stream.WriteAsync(request, ct).ConfigureAwait(false);
await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false);
var buffer = new byte[65536];
var len = 0;
while (len < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(len), ct).ConfigureAwait(false);
if (read == 0) break;
len += read;
// focas-mock replies with a single newline-terminated JSON object.
if (Array.IndexOf(buffer, (byte)'\n', 0, len) >= 0) break;
}
var newline = Array.IndexOf(buffer, (byte)'\n', 0, len);
var jsonLen = newline >= 0 ? newline : len;
var text = Encoding.UTF8.GetString(buffer, 0, jsonLen);
using var doc = JsonDocument.Parse(text);
var rc = doc.RootElement.GetProperty("rc").GetInt32();
if (rc != 0)
{
var message = doc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : "?";
throw new InvalidOperationException($"focas-mock {method} returned rc={rc} ({message}).");
}
// Return the "result" subtree cloned — document is disposed on exit.
return doc.RootElement.GetProperty("result").Clone();
}
private static int _nextId;
// ---- Parsing ----
private static (string Host, int Port) ParseEndpoint(string endpoint)
{
const string focasScheme = "focas://";
var body = endpoint.StartsWith(focasScheme, StringComparison.OrdinalIgnoreCase)
? endpoint[focasScheme.Length..]
: endpoint;
var slash = body.IndexOf('/');
if (slash >= 0) body = body[..slash];
var colon = body.LastIndexOf(':');
if (colon < 0) return (body, DefaultPort);
var host = body[..colon];
return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, DefaultPort);
}
/// <summary>
/// Map either a focas-mock DLL-stem profile (<c>fwlib30i64</c>) or a
/// OtOpcUa-style alias (<c>ThirtyOne_i</c>) to the matching
/// <see cref="FocasCncSeries"/>. Keeps tests able to assert
/// series-gated behaviour regardless of how the profile was pinned.
/// </summary>
private static FocasCncSeries? ParseSeries(string? profile)
{
if (string.IsNullOrWhiteSpace(profile)) return null;
var trimmed = profile.Trim();
// Try the OtOpcUa alias set first — it's a superset of human-readable names.
// The docker-compose profile names (thirtyone / zerod / ...) are accepted too so
// run-focas.ps1's -Profile argument threads straight through.
var aliasMapped = trimmed switch
{
"ThirtyOne_i" or "Thirty_i" or "ThirtyTwo_i"
or "thirtyone_i" or "thirty_i" or "thirtytwo_i"
or "thirtyone" or "thirty" or "thirtytwo"
or "fwlib30i64" => "ThirtyOne_i",
"Sixteen_i" or "sixteen_i" or "sixteen" or "FWLIB64" => "Sixteen_i",
"Zero_i_D" or "Zero_i_F" or "Zero_i_MF" or "Zero_i_TF"
or "zero_i_d" or "zero_i_f" or "zero_i_mf" or "zero_i_tf"
or "zerod" or "zerof" or "zeromf" or "zerotf"
or "fwlib0iD64" => "Zero_i_D",
"PowerMotion_i" or "powermotion_i" or "powermotion"
or "fwlib0DN64" => "PowerMotion_i",
_ => null,
};
return aliasMapped is not null && Enum.TryParse<FocasCncSeries>(aliasMapped, out var parsed)
? parsed : null;
}
}
[Xunit.CollectionDefinition(Name)]
public sealed class FocasSimCollection : Xunit.ICollectionFixture<FocasSimFixture>
{
public const string Name = "FocasSim";
}