using System.Net.Sockets; using System.Text; using System.Text.Json; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests; /// /// Fixture for the focas-mock simulator. Probes the Docker mock at /// collection init; if reachable, exposes helpers that drive the mock's /// admin surface (mock_load_profile, mock_patch, /// mock_reset, mock_schedule_alarms) so tests can seed /// deterministic state before exercising the managed driver. /// /// /// Single skip gate: is non-null when the /// localhost:8193 TCP probe fails. Tests call /// Assert.Skip. /// 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; } /// focas-mock profile stem the fixture should load (e.g. fwlib30i64, /// ThirtyOne_i — both resolve via the mock's alias table). Null when unset. public string? ExpectedProfile { get; } /// When the maps to a concrete /// , this is it. Null otherwise. public FocasCncSeries? ExpectedSeries { get; } /// Non-null when the mock probe failed — tests skip with this reason. 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 ---- /// /// Load a focas-mock profile. Accepts either the raw DLL-stem name /// (fwlib30i64) or the OtOpcUa-style alias (ThirtyOne_i); /// focas-mock's PROFILE_ALIASES resolves both. /// public Task LoadProfileAsync(string profileName, CancellationToken ct = default) => SendAdminAsync("mock_load_profile", new { profile = profileName }, ct); /// Deep-merge into the mock's current state. public Task PatchStateAsync(object state, CancellationToken ct = default) => SendAdminAsync("mock_patch", new { state }, ct); /// Reset the mock to the selected profile's default state. public Task ResetAsync(CancellationToken ct = default) => SendAdminAsync("mock_reset", new { }, ct); /// Install a time-scheduled alarm raise / clear sequence. public Task ScheduleAlarmsAsync(IEnumerable sequence, CancellationToken ct = default) => SendAdminAsync("mock_schedule_alarms", new { sequence }, ct); /// Low-level JSON round-trip. One TCP connection per call — matches /// how the shim talks to the mock; simpler than pooling. public async Task 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); } /// /// Map either a focas-mock DLL-stem profile (fwlib30i64) or a /// OtOpcUa-style alias (ThirtyOne_i) to the matching /// . Keeps tests able to assert /// series-gated behaviour regardless of how the profile was pinned. /// 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(aliasMapped, out var parsed) ? parsed : null; } } [Xunit.CollectionDefinition(Name)] public sealed class FocasSimCollection : Xunit.ICollectionFixture { public const string Name = "FocasSim"; }