Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeAddressingTests.cs
T
Joseph Doherty 4e22f02434
v2-ci / build (push) Successful in 4m33s
v2-ci / unit-tests (push) Failing after 17m25s
test(focas): thread the test cancellation token through the two new Task.Delay waits
2026-08-05 17:48:30 -04:00

276 lines
13 KiB
C#

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;
/// <summary>
/// Gitea #540 — authoring a fixed-tree tag through <c>/raw</c> → Browse device… produced a tag
/// the driver could never read. The browse leaf's full reference was <c>{deviceHost}/{path}</c>,
/// the commit mapper wrote it verbatim into <c>TagConfig.address</c>, and
/// <see cref="FocasAddress.TryParse"/> accepts no such form — so the driver skipped the tag at
/// init and every read returned <c>BadNodeIdUnknown</c>, indefinitely, while the deploy sealed
/// green and driver health stayed Healthy with zero errors.
/// <para>
/// Found on a real Makino Pro 5 / Fanuc 30i: 0 of 20 browse-committed tags read, while two
/// hand-authored <c>MACRO:</c> tags on the same device read Good.
/// </para>
/// </summary>
[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<bool> WaitForAsync(Func<bool> 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();
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// <c>FixedTree.Enabled</c> 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.
/// </summary>
[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);
}
/// <summary>A driver with no fixed-tree tags and the flag off must still not start the loop.</summary>
[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);
}
/// <summary>
/// A path this machine does not expose is a genuine <c>BadNodeIdUnknown</c> — the address
/// parsed, it simply names nothing here (a capability the series lacks, a renamed axis).
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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));
/// <summary>Records what <c>DiscoverAsync</c> emits, flattening the folder tree.</summary>
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) { }
}
}
}