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 #548 — a CNC reporting duplicate sibling axis names (the wonder Makino returns two
/// axis folders both called AA) made the Axes/ subtree untrustworthy: the
/// browser offered two identical folders commit-time collision detection had to refuse, and
/// the poll loop's name-keyed snapshot cache let the second axis silently overwrite the
/// first. Duplicated sibling names are now occurrence-suffixed (AA_1, AA_2)
/// everywhere — emitter, snapshot keys, servo loads, spindles — so both axes are
/// authorable and each reads its own axis's data.
///
[Trait("Category", "Unit")]
public sealed class FocasDuplicateSiblingNameTests
{
private const string Host = "focas://10.0.0.5:8193";
/// A fake modelled on the Makino: axes AA, Y, AA (slots 1–3) with distinct data.
private static FakeFocasClientFactory MakinoFactory() => new()
{
Customise = () =>
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.AddRange([new("A", "A"), new("Y", ""), new("A", "A")]);
c.DynamicByAxis[1] = new FocasDynamicSnapshot(1, 0, 0, 0, 0, 0, 0, 111, 0, 0, 0);
c.DynamicByAxis[2] = new FocasDynamicSnapshot(2, 0, 0, 0, 0, 0, 0, 222, 0, 0, 0);
c.DynamicByAxis[3] = new FocasDynamicSnapshot(3, 0, 0, 0, 0, 0, 0, 333, 0, 0, 0);
c.ServoLoads.AddRange([new("AA", 10), new("Y", 20), new("AA", 30)]);
// Two spindles whose displays also collide, with distinct data per slot.
c.SpindleNames.Clear();
c.SpindleNames.AddRange([new("S", "1", "", ""), new("S", "1", "", "")]);
c.SpindleLoads.AddRange([5.0, 7.0]);
c.SpindleMotorSpeeds.AddRange([40.0, 60.0]);
return c;
},
};
private static FocasDriver NewDriver(params FocasTagDefinition[] tags) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = true },
RawTags = FocasRawTags.Entries(tags),
}, "drv-1", MakinoFactory());
private static async Task StartedDriverAsync(params FocasTagDefinition[] tags)
{
var drv = NewDriver(tags);
await drv.InitializeAsync("{}", CancellationToken.None);
// LastFixedPollUtc alone is NOT "the first axis pass finished": the bootstrap stamps it
// after the sysinfo probe, before any axis snapshot is published (#591 — under load a
// read slipped into that window and saw BadNodeIdUnknown). Wait for the data itself:
// the last axis slot plus the servo/spindle caches this fake's first tick populates.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
var ready = false;
while (!ready && DateTime.UtcNow < deadline)
{
var st = drv.GetDeviceState(Host);
ready = st is not null
&& st.LastFixedPollUtc is not null
&& st.LastFixedSnapshots.ContainsKey(FocasDriver.FixedTreeReference(Host, "Axes/AA_2/AbsolutePosition"))
&& st.LastServoLoads.Count >= 3
&& st.LastSpindleLoads.Count >= 2
&& st.LastSpindleMotorSpeeds.Count >= 2;
if (!ready) await Task.Delay(10, TestContext.Current.CancellationToken);
}
ready.ShouldBeTrue("the fixed-tree loop never completed its first full poll");
return drv;
}
[Fact]
public async Task Duplicate_axes_browse_as_distinct_suffixed_folders()
{
var drv = await StartedDriverAsync();
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
var folders = builder.Folders.Select(f => f.BrowseName).ToList();
folders.ShouldContain("AA_1");
folders.ShouldContain("AA_2");
folders.ShouldContain("Y");
folders.ShouldNotContain("AA", customMessage:
"a bare AA folder would silently mean one of the two duplicate axes");
// Unique names stay bare — the disambiguation must not rename the rest of the machine.
folders.ShouldNotContain("Y_1");
// The emitted leaves are distinct, parseable addresses (the #540 invariant, held here
// for the duplicate set specifically).
var axisLeaves = builder.Variables
.Select(v => v.Info.FullName)
.Where(n => n.StartsWith("FIXED:Axes/AA", StringComparison.Ordinal))
.ToList();
axisLeaves.ShouldNotBeEmpty();
axisLeaves.ShouldAllBe(n => FocasAddress.TryParse(n) != null);
axisLeaves.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(axisLeaves.Count);
axisLeaves.ShouldContain("FIXED:Axes/AA_1/AbsolutePosition");
axisLeaves.ShouldContain("FIXED:Axes/AA_2/AbsolutePosition");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Each_duplicate_axis_reads_its_own_position()
{
// The runtime half of #548: before disambiguation the second AA overwrote the first's
// snapshot slot, so even a hand-authored tag could only ever see one axis.
var drv = await StartedDriverAsync(
new FocasTagDefinition("raw/aa1", Host, "FIXED:Axes/AA_1/AbsolutePosition", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/aa2", Host, "FIXED:Axes/AA_2/AbsolutePosition", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/y", Host, "FIXED:Axes/Y/AbsolutePosition", FocasDataType.Float64, Writable: false));
var read = await drv.ReadAsync(["raw/aa1", "raw/aa2", "raw/y"], CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[0].Value.ShouldBe(111.0); // slot 1's AA
read[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[1].Value.ShouldBe(333.0); // slot 3's AA — NOT slot 1's overwritten value
read[2].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[2].Value.ShouldBe(222.0); // the unique axis, untouched by disambiguation
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task The_bare_name_of_a_duplicate_set_reads_Bad_rather_than_one_arbitrary_axis()
{
// Whole-set suffixing: when two axes are both AA, bare AA ceases to exist. Resolving it
// to either axis would be the same silent-wrongness class #534/#540 were about.
var drv = await StartedDriverAsync(
new FocasTagDefinition("raw/bare", Host, "FIXED:Axes/AA/AbsolutePosition", FocasDataType.Float64, Writable: false));
var read = await drv.ReadAsync(["raw/bare"], CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Servo_loads_resolve_per_duplicate_axis()
{
// cnc_rdsvmeter answers per-axis in slot order, so the k-th AA load belongs to the k-th
// AA axis. Before #548 both landed on one name-keyed dictionary entry, last write wins.
var drv = await StartedDriverAsync(
new FocasTagDefinition("raw/load1", Host, "FIXED:Axes/AA_1/ServoLoad", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/load2", Host, "FIXED:Axes/AA_2/ServoLoad", FocasDataType.Float64, Writable: false));
// The servo-load pass runs after the axis pass inside the same tick; poll until both
// distinct keys are populated rather than racing it.
var state = drv.GetDeviceState(Host)!;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && state.LastServoLoads.Count < 3)
await Task.Delay(25, TestContext.Current.CancellationToken);
var read = await drv.ReadAsync(["raw/load1", "raw/load2"], CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[0].Value.ShouldBe(10.0);
read[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[1].Value.ShouldBe(30.0);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Duplicate_spindles_resolve_per_slot()
{
// The spindle read path matched by display name and took the FIRST index that matched,
// so a duplicate spindle name made the second spindle's data unreachable.
var drv = await StartedDriverAsync(
new FocasTagDefinition("raw/s1load", Host, "FIXED:Spindle/S1_1/Load", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s1speed", Host, "FIXED:Spindle/S1_1/MotorSpeedPct", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s2speed", Host, "FIXED:Spindle/S1_2/MotorSpeedPct", FocasDataType.Float64, Writable: false));
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1speed", "raw/s2speed"], CancellationToken.None);
read[0].Value.ShouldBe(5.0);
read[1].Value.ShouldBe(7.0);
read[2].Value.ShouldBe(40.0);
read[3].Value.ShouldBe(60.0);
read.ShouldAllBe(r => r.StatusCode == FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
/// 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) { }
}
}
}