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>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user