8b10d32728
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
100 lines
4.3 KiB
C#
100 lines
4.3 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|