276 lines
13 KiB
C#
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) { }
|
|
}
|
|
}
|
|
}
|