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
71 lines
2.4 KiB
C#
71 lines
2.4 KiB
C#
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();
|
|
}
|
|
}
|