using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series; /// /// Live-wire coverage (focas-mock) for #257 (cnc_statinfo status flags) and #266 (PMC /// range coalescing). Skips cleanly when OTOPCUA_FOCAS_SIM_ENDPOINT (default /// 10.100.0.35:8193, the otopcua-focas-fixture container) is unreachable. Read-only: no write path is exercised. /// [Collection(FocasSimCollection.Name)] public sealed class WireBackendStatusAndPmcTests { private readonly FocasSimFixture _fx; /// Initializes a new instance of the class. /// The FOCAS simulator fixture. public WireBackendStatusAndPmcTests(FocasSimFixture fx) => _fx = fx; private string DeviceHost => _fx.DeviceUri; /// The mock's cnc_statinfo flags surface through the Status/ subtree and an authored FIXED: tag. [Fact] public async Task Status_flags_populate_via_wire_backend() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; await _fx.LoadProfileAsync("FWLIB64", ct); await _fx.PatchStateAsync(new { sysinfo = new { addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ", series = "30i ", version = "A1.0", axes = "1 ", }, axis_names = new[] { "X" }, statinfo = new { aut = 1, run = 3, motion = 1, mstb = 0, emergency = 0, alarm = 1, edit = 0 }, }, ct); var driver = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost)], RawTags = FocasRawTags.Entries( new FocasTagDefinition("RunFlag", DeviceHost, "FIXED:Status/Run", FocasDataType.Int32, Writable: false)), Probe = new FocasProbeOptions { Enabled = false }, FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(100), ProgramPollInterval = TimeSpan.FromMilliseconds(200), }, }, driverInstanceId: "focas-wire-status", clientFactory: new WireFocasClientFactory()); await using (driver) { await driver.InitializeAsync("{}", ct); await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus: not null }, TimeSpan.FromSeconds(5)); var state = driver.GetDeviceState(DeviceHost); state.ShouldNotBeNull(); state.LastStatus.ShouldNotBeNull(); state.LastStatus.Run.ShouldBe(3); state.LastStatus.Alarm.ShouldBe(1); var snaps = await driver.ReadAsync(["RunFlag", $"{DeviceHost}/Status/Alarm", $"{DeviceHost}/Status/RunText"], ct); snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good); snaps[0].Value.ShouldBe(3); snaps[1].Value.ShouldBe(1); snaps[2].Value.ShouldBe("START"); // The flags follow the machine. await _fx.PatchStateAsync(new { statinfo = new { run = 1, emergency = 1 } }, ct); await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus.Run: 1 }, TimeSpan.FromSeconds(5)); driver.GetDeviceState(DeviceHost)!.LastStatus!.Emergency.ShouldBe(1); } } /// /// A poll batch of adjacent PMC bytes is served by ONE range read against the live wire, every tag gets /// its own value, and a tag outside the merge window still reads through the per-tag path. Seeds are /// byte-typed: the mock serves one entry per ADDRESS for every PMC type, which matches the real /// byte-addressed range only for width 1. /// [Fact] public async Task Adjacent_pmc_bytes_coalesce_into_one_range_read_via_wire_backend() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; await _fx.LoadProfileAsync("FWLIB64", ct); await _fx.PatchStateAsync(new { pmc = new { R = new Dictionary { ["200"] = new { type = "byte", value = 17 }, ["201"] = new { type = "byte", value = 200 }, ["202"] = new { type = "byte", value = 0b0000_0100 }, ["203"] = new { type = "byte", value = 9 }, ["900"] = new { type = "byte", value = 33 }, }}, }, ct); var counting = new CountingFactory(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost)], RawTags = FocasRawTags.Entries( new FocasTagDefinition("R200", DeviceHost, "R200", FocasDataType.Byte, Writable: false), new FocasTagDefinition("R201", DeviceHost, "R201", FocasDataType.Byte, Writable: false), new FocasTagDefinition("R202b2", DeviceHost, "R202.2", FocasDataType.Bit, Writable: false), new FocasTagDefinition("R203", DeviceHost, "R203", FocasDataType.Byte, Writable: false), new FocasTagDefinition("R900", DeviceHost, "R900", FocasDataType.Byte, Writable: false)), Probe = new FocasProbeOptions { Enabled = false }, }, driverInstanceId: "wire-pmc-coalesce", clientFactory: counting); await using (drv) { await drv.InitializeAsync("{}", ct); var snaps = await drv.ReadAsync(["R200", "R201", "R202b2", "R203", "R900"], ct); snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good); Convert.ToInt32(snaps[0].Value).ShouldBe(17); Convert.ToInt32(snaps[1].Value).ShouldBe(-56); // 200 as a signed byte snaps[2].Value.ShouldBe(true); Convert.ToInt32(snaps[3].Value).ShouldBe(9); Convert.ToInt32(snaps[4].Value).ShouldBe(33); counting.Client.ShouldNotBeNull(); counting.Client.RangeReads.ShouldBe(1, "R200..R203 merge into one pmc_rdpmcrng"); counting.Client.SingleReads.ShouldBe(1, "R900 is outside the gap window and reads on its own"); } } private sealed class CountingFactory : IFocasClientFactory { private readonly WireFocasClientFactory _inner = new(); public CountingClient? Client { get; private set; } public void EnsureUsable() => _inner.EnsureUsable(); public IFocasClient Create() => Client = new CountingClient(_inner.Create()); } private sealed class CountingClient(IFocasClient inner) : IFocasClient { public int RangeReads { get; private set; } public int SingleReads { get; private set; } public bool IsConnected => inner.IsConnected; public Task ConnectAsync(FocasHostAddress a, TimeSpan t, CancellationToken ct) => inner.ConnectAsync(a, t, ct); public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct) { SingleReads++; return inner.ReadAsync(a, t, ct); } public Task<(IReadOnlyList? slots, uint status)> ReadPmcSlotsAsync(string l, FocasDataType t, int s, int e, CancellationToken ct) { RangeReads++; return inner.ReadPmcSlotsAsync(l, t, s, e, ct); } public Task WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => inner.WriteAsync(a, t, v, ct); public Task ProbeAsync(CancellationToken ct) => inner.ProbeAsync(ct); public Task> ReadAlarmsAsync(CancellationToken ct) => inner.ReadAlarmsAsync(ct); public Task GetSysInfoAsync(CancellationToken ct) => inner.GetSysInfoAsync(ct); public Task> GetAxisNamesAsync(CancellationToken ct) => inner.GetAxisNamesAsync(ct); public Task> GetSpindleNamesAsync(CancellationToken ct) => inner.GetSpindleNamesAsync(ct); public Task ReadDynamicAsync(int i, CancellationToken ct) => inner.ReadDynamicAsync(i, ct); public Task GetProgramInfoAsync(CancellationToken ct) => inner.GetProgramInfoAsync(ct); public Task GetStatusAsync(CancellationToken ct) => inner.GetStatusAsync(ct); public Task GetTimerAsync(FocasTimerKind k, CancellationToken ct) => inner.GetTimerAsync(k, ct); public Task> GetServoLoadsAsync(CancellationToken ct) => inner.GetServoLoadsAsync(ct); public Task> GetSpindleLoadsAsync(CancellationToken ct) => inner.GetSpindleLoadsAsync(ct); public Task> GetSpindleMotorSpeedsAsync(CancellationToken ct) => inner.GetSpindleMotorSpeedsAsync(ct); public void Dispose() => inner.Dispose(); } private static async Task WaitFor(Func pred, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (pred()) return; await Task.Delay(50); } } }