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 #540 — authoring a fixed-tree tag through /raw → Browse device… produced a tag /// the driver could never read. The browse leaf's full reference was {deviceHost}/{path}, /// the commit mapper wrote it verbatim into TagConfig.address, and /// accepts no such form — so the driver skipped the tag at /// init and every read returned BadNodeIdUnknown, indefinitely, while the deploy sealed /// green and driver health stayed Healthy with zero errors. /// /// Found on a real Makino Pro 5 / Fanuc 30i: 0 of 20 browse-committed tags read, while two /// hand-authored MACRO: tags on the same device read Good. /// /// [Trait("Category", "Unit")] public sealed class FocasFixedTreeAddressingTests { private const string Host = "focas://10.0.0.5:8193"; private static FocasDriver NewDriver( FakeFocasClientFactory factory, bool fixedTreeEnabled, params FocasTagDefinition[] tags) => new(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Probe = new FocasProbeOptions { Enabled = false }, FixedTree = new FocasFixedTreeOptions { Enabled = fixedTreeEnabled }, RawTags = FocasRawTags.Entries(tags), }, "drv-1", factory); private static async Task WaitForAsync(Func predicate, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (predicate()) return true; await Task.Delay(25, TestContext.Current.CancellationToken); } return predicate(); } /// /// The headline regression, stated over the WHOLE emitted tree rather than a sample: nothing /// the browser can offer is unauthorable. Sampling is what let this ship — the tree looked /// right, and no test ever asked whether the strings hanging off it were addresses. /// [Fact] public async Task Every_browse_emitted_leaf_carries_an_address_the_driver_can_parse() { var drv = NewDriver(new FakeFocasClientFactory(), fixedTreeEnabled: true); await drv.InitializeAsync("{}", CancellationToken.None); (await WaitForAsync( () => drv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5))) .ShouldBeTrue("the fixed-tree cache never filled, so the tree under test was empty"); var builder = new RecordingBuilder(); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Variables.ShouldNotBeEmpty(); foreach (var (browseName, info) in builder.Variables) { FocasAddress.TryParse(info.FullName) .ShouldNotBeNull($"leaf '{browseName}' emitted '{info.FullName}', which is not an address"); // The specific shape that broke it: the device host leaking into the address, where it // is both unparseable and a second routing channel competing with the tag's Device row. info.FullName.ShouldNotContain("10.0.0.5"); } await drv.ShutdownAsync(CancellationToken.None); } /// /// The end-to-end claim the issue is about: take what the browser offers, author it as a tag, /// and read it. Anything short of this passed before the fix. /// [Fact] public async Task A_leaf_authored_from_the_browse_tree_reads_its_value() { // 1. Browse. var browseDrv = NewDriver(new FakeFocasClientFactory(), fixedTreeEnabled: true); await browseDrv.InitializeAsync("{}", CancellationToken.None); (await WaitForAsync( () => browseDrv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5))) .ShouldBeTrue(); var builder = new RecordingBuilder(); await browseDrv.DiscoverAsync(builder, CancellationToken.None); await browseDrv.ShutdownAsync(CancellationToken.None); var seriesLeaf = builder.Variables.Single(v => v.Info.FullName == "FIXED:Identity/SeriesNumber"); // 2. Commit it as a raw tag, exactly as the browse-commit mapper does — the leaf's full // reference becomes the tag's address, and its RawPath is where the operator put it. var runDrv = NewDriver( new FakeFocasClientFactory(), fixedTreeEnabled: true, new FocasTagDefinition( "focas-cnc/cnc/FOCAS/cnc/Identity/SeriesNumber", Host, seriesLeaf.Info.FullName, FocasDataType.String, Writable: false)); await runDrv.InitializeAsync("{}", CancellationToken.None); (await WaitForAsync( () => runDrv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5))) .ShouldBeTrue(); // 3. Read it by RawPath, which is the reference the v3 server uses. var read = await runDrv.ReadAsync(["focas-cnc/cnc/FOCAS/cnc/Identity/SeriesNumber"], CancellationToken.None); read[0].StatusCode.ShouldBe(FocasStatusMapper.Good); read[0].Value.ShouldBe("30i"); // FakeFocasClient.SysInfo.Series await runDrv.ShutdownAsync(CancellationToken.None); } /// /// FixedTree.Enabled defaults to false and the universal browser force-enables it only /// for the duration of a browse. Without this, an operator would browse the tree, commit /// leaves, deploy, and land on a driver whose poll loop never starts — every tag Bad, with a /// perfectly correct address. Authoring a FIXED: tag is the request for the tree. /// [Fact] public async Task An_authored_fixed_tag_arms_the_poll_loop_with_the_config_flag_off() { var drv = NewDriver( new FakeFocasClientFactory(), fixedTreeEnabled: false, new FocasTagDefinition("raw/Program", Host, "FIXED:Program/Name", FocasDataType.String, Writable: false)); await drv.InitializeAsync("{}", CancellationToken.None); (await WaitForAsync( () => drv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5))) .ShouldBeTrue("an authored FIXED: tag must start the fixed-tree loop on its own"); await drv.ShutdownAsync(CancellationToken.None); } /// A driver with no fixed-tree tags and the flag off must still not start the loop. [Fact] public async Task An_ordinary_tag_does_not_arm_the_poll_loop() { var drv = NewDriver( new FakeFocasClientFactory(), fixedTreeEnabled: false, new FocasTagDefinition("raw/Run", Host, "R100", FocasDataType.Byte)); await drv.InitializeAsync("{}", CancellationToken.None); await Task.Delay(300, TestContext.Current.CancellationToken); drv.GetDeviceState(Host)!.FixedTreeCache.ShouldBeNull(); await drv.ShutdownAsync(CancellationToken.None); } /// /// A path this machine does not expose is a genuine BadNodeIdUnknown — the address /// parsed, it simply names nothing here (a capability the series lacks, a renamed axis). /// [Fact] public async Task A_fixed_path_the_machine_does_not_expose_reads_BadNodeIdUnknown() { var drv = NewDriver( new FakeFocasClientFactory(), fixedTreeEnabled: true, new FocasTagDefinition("raw/Ghost", Host, "FIXED:Axes/Q/AbsolutePosition", FocasDataType.Float64, Writable: false)); await drv.InitializeAsync("{}", CancellationToken.None); (await WaitForAsync( () => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(5))) .ShouldBeTrue(); var read = await drv.ReadAsync(["raw/Ghost"], CancellationToken.None); read[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); await drv.ShutdownAsync(CancellationToken.None); } /// /// Nothing in the fixed tree is a writable location on the CNC — it is a read-out of machine /// state. Refused before a connection is opened. /// [Fact] public async Task A_fixed_tree_tag_refuses_writes() { var drv = NewDriver( new FakeFocasClientFactory(), fixedTreeEnabled: true, // Writable:true on purpose — the refusal must come from the address kind, not from the // tag's own flag, or an operator could author their way past it. new FocasTagDefinition("raw/Series", Host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: true)); await drv.InitializeAsync("{}", CancellationToken.None); var results = await drv.WriteAsync( [new WriteRequest("raw/Series", "nope")], CancellationToken.None); results[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); await drv.ShutdownAsync(CancellationToken.None); } /// /// A dead CNC must not keep serving its last fixed-tree values as Good. The poll loop /// swallows a failed tick by design (LogDebug + retry), so nothing else marks the caches /// stale — and unlike an area read, a fixed-tree read issues no traffic that could fail. /// This was harmless while nothing could bind to the tree; it is not harmless now. /// [Fact] public async Task Fixed_tree_values_go_bad_once_the_device_stops_answering() { var drv = NewDriver( new FakeFocasClientFactory(), fixedTreeEnabled: true, new FocasTagDefinition("raw/Series", Host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: false)); await drv.InitializeAsync("{}", CancellationToken.None); (await WaitForAsync( () => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(5))) .ShouldBeTrue(); (await drv.ReadAsync(["raw/Series"], CancellationToken.None))[0] .StatusCode.ShouldBe(FocasStatusMapper.Good); // Age the last answered poll past the staleness budget. Reaching into DeviceState rather // than sleeping keeps this deterministic and instant; the budget itself is asserted below. var state = drv.GetDeviceState(Host)!; state.FixedTreeCts?.Cancel(); state.LastFixedPollUtc = DateTime.UtcNow - FocasDriver.FixedTreeStaleAfter(TimeSpan.FromMilliseconds(250)) - TimeSpan.FromSeconds(1); var stale = await drv.ReadAsync(["raw/Series"], CancellationToken.None); stale[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); await drv.ShutdownAsync(CancellationToken.None); } /// /// The staleness budget tracks the poll cadence but never drops below 30 s, so an ordinarily /// slow CNC on the sub-second default cadence does not flap between Good and Bad. /// [Theory] [InlineData(250, 30_000)] // default cadence — the 30 s floor wins [InlineData(1_000, 30_000)] // 8 s of cadence still under the floor [InlineData(10_000, 80_000)] // slow cadence — 8x the interval wins public void Stale_budget_is_eight_cadences_floored_at_thirty_seconds(int pollMs, int expectedMs) => FocasDriver.FixedTreeStaleAfter(TimeSpan.FromMilliseconds(pollMs)) .ShouldBe(TimeSpan.FromMilliseconds(expectedMs)); /// Records what DiscoverAsync emits, flattening the folder tree. 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) { } } } }