using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Gitea #257 — cnc_statinfo (Auto / Run / Motion / Mstb / Emergency / Alarm / Edit / TmMode) /// surfaces as a read-only Status/ fixed-tree subtree, authorable as FIXED:Status/Run. /// The wire client already decoded the call (FocasWireClient.ReadStatusAsync) but nothing consumed it. /// [Trait("Category", "Unit")] public sealed class FocasStatusFlagsTests { private const string Host = "focas://10.0.0.5:8193"; private static FocasDriver NewDriver(FakeFocasClient client, params FocasTagDefinition[] tags) => new(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Probe = new FocasProbeOptions { Enabled = false }, RawTags = FocasRawTags.Entries(tags), FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10), ProgramPollInterval = TimeSpan.FromMilliseconds(10), }, }, "drv-1", new FakeFocasClientFactory { Customise = () => client }); private static async Task WaitAsync(Func predicate) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (DateTime.UtcNow < deadline && !predicate()) await Task.Delay(15); predicate().ShouldBeTrue("condition never became true"); } [Fact] public void The_wire_status_block_maps_field_for_field() { var s = Wire.WireFocasClient.MapStatus(new Wire.WireStatus(Auto: 1, Run: 2, Motion: 3, Mstb: 4, Emergency: 5, Alarm: 6, Edit: 7, TmMode: 8)); s.ShouldBe(new FocasStatus(1, 2, 3, 4, 5, 6, 7, 8)); } [Theory] [InlineData(0, "RESET")] [InlineData(1, "STOP")] [InlineData(2, "HOLD")] [InlineData(3, "START")] [InlineData(4, "MSTR")] [InlineData(9, "Run9")] public void Run_state_text(int run, string text) => FocasRunState.ToText(run).ShouldBe(text); [Fact] public async Task Status_flags_read_good_through_the_fixed_tree_references() { var client = new FakeFocasClient { Status = new FocasStatus(Auto: 1, Run: 3, Motion: 1, Mstb: 2, Emergency: 0, Alarm: 1, Edit: 0, TmMode: 4) }; var drv = NewDriver(client); await drv.InitializeAsync("{}", CancellationToken.None); await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null); string[] fields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"]; int[] expected = [1, 3, 1, 2, 0, 1, 0, 4]; var read = await drv.ReadAsync([.. fields.Select(f => $"{Host}/Status/{f}")], CancellationToken.None); for (var i = 0; i < fields.Length; i++) { read[i].StatusCode.ShouldBe(FocasStatusMapper.Good, fields[i]); read[i].Value.ShouldBe(expected[i], fields[i]); } var text = await drv.ReadAsync([$"{Host}/Status/RunText"], CancellationToken.None); text[0].Value.ShouldBe("START"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task An_authored_FIXED_Status_tag_reads_the_flag() { var client = new FakeFocasClient { Status = new FocasStatus(1, 3, 1, 0, 1, 0, 0, 0) }; var drv = NewDriver(client, new FocasTagDefinition("EStop", Host, "FIXED:Status/Emergency", FocasDataType.Int32, Writable: false)); await drv.InitializeAsync("{}", CancellationToken.None); await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null); var snap = (await drv.ReadAsync(["EStop"], CancellationToken.None))[0]; snap.StatusCode.ShouldBe(FocasStatusMapper.Good); snap.Value.ShouldBe(1); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Status_updates_follow_the_machine() { var client = new FakeFocasClient { Status = new FocasStatus(1, 0, 0, 0, 0, 0, 0, 0) }; var drv = NewDriver(client); await drv.InitializeAsync("{}", CancellationToken.None); await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null); client.Status = new FocasStatus(1, 3, 1, 0, 0, 0, 0, 0); await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus?.Run == 3); (await drv.ReadAsync([$"{Host}/Status/Run"], CancellationToken.None))[0].Value.ShouldBe(3); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task A_cnc_that_refuses_statinfo_gets_no_Status_node_and_the_rest_of_the_tree_survives() { var client = new FakeFocasClient { ThrowOnStatus = true }; var drv = NewDriver(client); await drv.InitializeAsync("{}", CancellationToken.None); await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null); var builder = new RecordingBuilder(); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldNotContain(f => f.BrowseName == "Status"); builder.Variables.ShouldContain(v => v.Info.FullName == "FIXED:Identity/SeriesNumber"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Discovery_emits_every_status_leaf_with_a_parseable_read_only_address() { var drv = NewDriver(new FakeFocasClient()); await drv.InitializeAsync("{}", CancellationToken.None); await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null); var builder = new RecordingBuilder(); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldContain(f => f.BrowseName == "Status"); var leaves = builder.Variables.Where(v => v.Info.FullName.StartsWith("FIXED:Status/", StringComparison.Ordinal)).ToList(); leaves.Select(l => l.BrowseName).ShouldBe( ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode", "RunText"], ignoreOrder: true); foreach (var (_, info) in leaves) { FocasAddress.TryParse(info.FullName).ShouldNotBeNull(); info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } await drv.ShutdownAsync(CancellationToken.None); } private sealed class RecordingBuilder : IAddressSpaceBuilder { public List<(string BrowseName, string DisplayName)> Folders { get; } = []; public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } public void AddProperty(string name, DriverDataType type, object? value) { } private sealed class Handle(string fullRef) : IVariableHandle { public string FullReference => fullRef; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } } }