fix(focas): disambiguate duplicate sibling axis/spindle names in the fixed tree (#548)
A CNC can report duplicate sibling names — the wonder Makino returns two
axes both called AA — which made the Axes/ subtree untrustworthy: the
browser offered two identical folders the commit gate had to refuse, and
the poll loop's name-keyed snapshot cache let the second axis silently
overwrite the first. Spindles and servo loads had the same collision in
their own keying, plus a resolve-to-first-match on the spindle read path.
Scheme: every member of a collision set gets a 1-based occurrence-ordinal
suffix in slot order (AA_1, AA_2); unique names stay bare. Chosen over the
alternatives deliberately:
- Names, not slot numbers, stay the primary identity — the segment map is
re-derived from the live device each session, so a name-keyed path
follows the physical axis across config changes, where a slot-qualified
path would silently rebind on any axis-list shift and rename every axis
on every machine for one pathological CNC.
- The WHOLE collision set is suffixed: bare AA ceases to exist when
ambiguous, so a tag authored against it reads BadNodeIdUnknown rather
than one arbitrary axis's data — the #534/#540 silent-wrongness class.
- Occurrence ordinal, not absolute slot: inserting/removing an unrelated
axis never renames an existing duplicate pair. If the collision-set
membership itself changes, segments change on the next browse and stale
authored segments read Bad, never another axis's data.
One pure helper (FixedTreeNames.Disambiguate, collision-proof against
genuine names by suffix escalation) feeds FocasFixedTreeCache.AxisSegments
/ SpindleSegments, and the emitter, axis snapshot keys, servo-load keys
(cnc_rdsvmeter answers in slot order, so the k-th AA load belongs to the
k-th AA axis) and the spindle read resolution all consume those — they can
no longer disagree. The nameless-spindle S{n} fallback applies before
disambiguation so a fallback name colliding with a real one is handled
like any other duplicate.
Tests are red-first on the pre-fix driver (all five driver-level tests
fail on master's code): browse emits AA_1/AA_2 and no bare AA, each
duplicate reads its own position and servo load, duplicate spindles
resolve per slot, bare AA reads Bad. The wire half runs live against
focas-mock (duplicate names survive the managed wire parse and publish as
suffixed segments); the mock's dynamic store is name-keyed so the
distinct-values half stays with the offline tests. FOCAS 327/327,
integration 11/11, FOCAS.Cli 52, AdminUI 1113, ControlPlane 231.
Closes #548.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
+99
@@ -0,0 +1,99 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #548, wire half — duplicate sibling axis names must survive the managed wire
|
||||
/// client's <c>cnc_rdaxisname</c> parse (no dedupe, no error) and come out of the driver's
|
||||
/// bootstrap as disambiguated segments, exercised against the real focas-mock over TCP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The mock's own dynamic store is keyed by axis NAME, so it cannot serve two duplicate
|
||||
/// axes <em>distinct</em> position values — that half of #548 is pinned by the offline
|
||||
/// <c>FocasDuplicateSiblingNameTests</c>, which drive slot-distinct snapshots through the
|
||||
/// fake client. What only this test proves is the wire path: the duplicate names reach the
|
||||
/// driver intact and the published snapshot keys are the suffixed segments.
|
||||
/// </remarks>
|
||||
[Collection(FocasSimCollection.Name)]
|
||||
public sealed class WireBackendDuplicateAxisTests
|
||||
{
|
||||
private readonly FocasSimFixture _fx;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="WireBackendDuplicateAxisTests"/> class.</summary>
|
||||
/// <param name="fx">The FOCAS simulator fixture.</param>
|
||||
public WireBackendDuplicateAxisTests(FocasSimFixture fx) => _fx = fx;
|
||||
|
||||
private string DeviceHost => _fx.DeviceUri;
|
||||
|
||||
/// <summary>Duplicate axis names arrive intact off the wire and publish as suffixed segments.</summary>
|
||||
[Fact]
|
||||
public async Task Duplicate_axis_names_disambiguate_through_the_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
sysinfo = new
|
||||
{
|
||||
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
|
||||
series = "30i ", version = "A1.0", axes = "3 ",
|
||||
},
|
||||
// The Makino shape: two axes rendering the same display name, one unique between them.
|
||||
axis_names = new[] { "AA", "Y", "AA" },
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
RawTags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
ProgramPollInterval = TimeSpan.Zero,
|
||||
TimerPollInterval = TimeSpan.Zero,
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-dup-axes", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await WaitFor(() =>
|
||||
driver.GetDeviceState(DeviceHost) is { FixedTreeCache: not null }, TimeSpan.FromSeconds(5));
|
||||
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
state.ShouldNotBeNull();
|
||||
state.FixedTreeCache.ShouldNotBeNull();
|
||||
|
||||
// The wire client must not collapse or drop the duplicates...
|
||||
state.FixedTreeCache.Axes.Count.ShouldBe(3);
|
||||
state.FixedTreeCache.Axes.Select(a => a.Display).ShouldBe(["AA", "Y", "AA"]);
|
||||
// ...and the driver must disambiguate them.
|
||||
state.FixedTreeCache.AxisSegments.ShouldBe(["AA_1", "Y", "AA_2"]);
|
||||
|
||||
// Both duplicate axes publish their own snapshot slot; nothing publishes bare AA.
|
||||
await WaitFor(() =>
|
||||
state.LastFixedSnapshots.ContainsKey($"{DeviceHost}/Axes/AA_2/AbsolutePosition"),
|
||||
TimeSpan.FromSeconds(3));
|
||||
state.LastFixedSnapshots.ContainsKey($"{DeviceHost}/Axes/AA_1/AbsolutePosition").ShouldBeTrue();
|
||||
state.LastFixedSnapshots.ContainsKey($"{DeviceHost}/Axes/Y/AbsolutePosition").ShouldBeTrue();
|
||||
state.LastFixedSnapshots.ContainsKey($"{DeviceHost}/Axes/AA/AbsolutePosition").ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #548 — the sibling-name disambiguator that makes a CNC reporting two axes both
|
||||
/// called <c>AA</c> publishable as <c>AA_1</c> / <c>AA_2</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FixedTreeNamesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Unique_names_pass_through_unchanged()
|
||||
{
|
||||
FixedTreeNames.Disambiguate(["X", "Y", "Z"]).ShouldBe(["X", "Y", "Z"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_member_of_a_collision_set_is_suffixed_in_slot_order()
|
||||
{
|
||||
// The whole set, not just the second occurrence: bare "AA" must cease to exist when
|
||||
// ambiguous, or it would silently mean "one of the two".
|
||||
FixedTreeNames.Disambiguate(["AA", "Y", "AA"]).ShouldBe(["AA_1", "Y", "AA_2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unrelated_name_between_duplicates_does_not_shift_their_ordinals()
|
||||
{
|
||||
// The ordinal counts occurrences of the SAME name, not positions in the list — adding an
|
||||
// unrelated axis never renames an existing duplicate pair.
|
||||
FixedTreeNames.Disambiguate(["B", "AA", "Y", "AA"]).ShouldBe(["B", "AA_1", "Y", "AA_2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_grouping_is_case_insensitive()
|
||||
{
|
||||
// The snapshot cache and the read path both compare OrdinalIgnoreCase, so "Aa" and "AA"
|
||||
// would collide there — they must be treated as one collision set here too.
|
||||
FixedTreeNames.Disambiguate(["Aa", "AA"]).ShouldBe(["Aa_1", "AA_2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_generated_segment_never_collides_with_a_genuine_name()
|
||||
{
|
||||
// Pathological but must not silently alias: a real sibling already named "S_1" claims
|
||||
// that segment, so the duplicate set escalates past it.
|
||||
FixedTreeNames.Disambiguate(["S_1", "S", "S"]).ShouldBe(["S_1", "S_2", "S_3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Three_way_collisions_number_sequentially()
|
||||
{
|
||||
FixedTreeNames.Disambiguate(["AA", "AA", "AA"]).ShouldBe(["AA_1", "AA_2", "AA_3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_output_is_always_unique()
|
||||
{
|
||||
var segments = FixedTreeNames.Disambiguate(["A", "A_1", "A", "A_2", "A"]);
|
||||
segments.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(segments.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_list_yields_an_empty_list()
|
||||
{
|
||||
FixedTreeNames.Disambiguate([]).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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 #548 — a CNC reporting duplicate sibling axis names (the wonder Makino returns two
|
||||
/// axis folders both called <c>AA</c>) made the <c>Axes/</c> 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 (<c>AA_1</c>, <c>AA_2</c>)
|
||||
/// everywhere — emitter, snapshot keys, servo loads, spindles — so both axes are
|
||||
/// authorable and each reads its own axis's data.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasDuplicateSiblingNameTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
/// <summary>A fake modelled on the Makino: axes AA, Y, AA (slots 1–3) with distinct data.</summary>
|
||||
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, 7]);
|
||||
c.SpindleMaxRpms.AddRange([8_000, 12_000]);
|
||||
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<FocasDriver> StartedDriverAsync(params FocasTagDefinition[] tags)
|
||||
{
|
||||
var drv = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && drv.GetDeviceState(Host)?.LastFixedPollUtc is null)
|
||||
await Task.Delay(25, TestContext.Current.CancellationToken);
|
||||
drv.GetDeviceState(Host)!.LastFixedPollUtc.ShouldNotBeNull("the fixed-tree loop never completed a 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.Int32, Writable: false),
|
||||
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Int32, Writable: false),
|
||||
new FocasTagDefinition("raw/s1max", Host, "FIXED:Spindle/S1_1/MaxRpm", FocasDataType.Int32, Writable: false),
|
||||
new FocasTagDefinition("raw/s2max", Host, "FIXED:Spindle/S1_2/MaxRpm", FocasDataType.Int32, Writable: false));
|
||||
|
||||
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1max", "raw/s2max"], CancellationToken.None);
|
||||
|
||||
read[0].Value.ShouldBe(5);
|
||||
read[1].Value.ShouldBe(7);
|
||||
read[2].Value.ShouldBe(8_000);
|
||||
read[3].Value.ShouldBe(12_000);
|
||||
read.ShouldAllBe(r => r.StatusCode == FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user