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>
193 lines
8.4 KiB
C#
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";
|
|
}
|