fix(focas): browse-committed fixed-tree tags are readable (Gitea #540)
Authoring FOCAS tags through /raw -> Browse device... produced tags the driver
could never read. Every committed tag read BadNodeIdUnknown forever, and the
failure was silent end to end: the AdminUI accepted it, the deploy gate sealed
green, and the driver reported Healthy with 0 errors while ignoring the tags.
Net effect: the whole fixed-tree surface could not be published through any v3
authoring path. Found cutting wonder-app-vd03 over to v3 against a real Makino
Pro 5 / Fanuc 30i, where 0 of 20 committed tags read while two hand-authored
MACRO: tags on the same device read Good.
A captured browse leaf's id IS its DriverAttributeInfo.FullName, and the fixed
tree emitted "{deviceHost}/{path}" there -- an internal cache key, not an
address. Both authoring paths wrote it verbatim into TagConfig.address, and
FocasAddress.TryParse accepts no such form, so the driver skipped the tags at
init.
Fixed at the emitter rather than by stripping the host at commit, which fixes
the browse-commit flow and the manual picker's tree-select together and leaves
one canonical address form:
- FocasAddress gains a fourth kind, FIXED:<path>, and moves to Contracts so the
AdminUI and ControlPlane can parse without referencing the runtime driver
(#526). A FIXED: read is served from the fixed-tree snapshot the poll loop
already maintains -- no area read, no connection. Writes refuse: nothing in
that tree is a writable location on the CNC.
- The address carries no device. A tag's device comes from its Device row, and
a second routing channel for one fact is what created this defect.
- An authored FIXED: tag arms the fixed-tree poll loop by itself. Enabled
defaults false and the browser force-enables it only for a browse, so the
operator would otherwise browse, commit, deploy, and land on a driver whose
loop never starts -- every tag Bad with a correct address.
- Fixed-tree values are now dated from the last answered poll and go
BadCommunicationError once it is stale (8 cadences, floored at 30s). The poll
loop swallows a failed tick by design, so a dead CNC would otherwise serve its
last snapshot as Good indefinitely -- harmless while nothing could bind to the
tree, not harmless now.
Guardrails, so this class cannot recur silently:
- FocasTagConfigModel.Validate() runs the real parser instead of a non-empty
check, and FocasTagDefinitionFactory.Inspect() warns at the deploy gate, which
covers CSV/API config that never touches the modal.
- The manual picker offered axis/spindle/program/status "parameter groups" --
none of which is a FOCAS addressing space -- and its own default value,
axis:5, was unusable. It now composes PMC / PARAM: / MACRO: through
FocasAddress, so what it emits is parseable by construction.
Tests assert addresses ROUND-TRIP through the parser rather than matching an
expected string: the previous FocasAddressBuilderTests pinned "axis:5" exactly,
which is how a green suite coexisted with a picker nobody could use. Verified
red-first against the old emitter. FOCAS 308, AdminUI 1095, ControlPlane 169,
Core 581 -- all pass, 0 warnings.
The remaining half of #540 -- a driver reporting Healthy/0 errors while
skipping tags at init -- is fleet-wide (eight drivers do this) and is filed
separately rather than patched per-driver.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>FIXED:</c> address form added by Gitea #540, and the guarantee that adding it did not
|
||||
/// disturb the three forms that were already there.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasAddressFixedFormTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("FIXED:Identity/SeriesNumber", "Identity/SeriesNumber")]
|
||||
[InlineData("FIXED:Axes/X/AbsolutePosition", "Axes/X/AbsolutePosition")]
|
||||
[InlineData("FIXED:Timers/PowerOnSeconds", "Timers/PowerOnSeconds")]
|
||||
[InlineData("FIXED:Spindle/S1/Load", "Spindle/S1/Load")]
|
||||
[InlineData("fixed:Program/Name", "Program/Name")] // prefix is case-insensitive
|
||||
[InlineData(" FIXED:Program/Name ", "Program/Name")] // outer whitespace tolerated
|
||||
public void Parses_a_fixed_tree_path(string input, string expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Fixed);
|
||||
parsed.FixedPath.ShouldBe(expectedPath);
|
||||
parsed.Canonical.ShouldBe("FIXED:" + expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("FIXED:")] // no path
|
||||
[InlineData("FIXED: ")]
|
||||
[InlineData("FIXED:/Identity/Series")] // leading separator
|
||||
[InlineData("FIXED:Identity/Series/")] // trailing separator
|
||||
[InlineData("FIXED:Identity//Series")] // empty segment
|
||||
[InlineData("FIXED:Identity/ /Series")] // blank segment
|
||||
[InlineData("FIXED:MACRO:500")] // a colon would make the grammar ambiguous
|
||||
public void Rejects_a_path_that_is_not_addressable(string input)
|
||||
=> FocasAddress.TryParse(input).ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// The exact strings the defect produced. Each names a real fixed-tree field but carries the
|
||||
/// device host, which is the tag's Device row's job — so they must still be rejected rather
|
||||
/// than quietly accepted with the host treated as a path segment.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("10.201.31.5:8193/Identity/SeriesNumber")]
|
||||
[InlineData("focas://10.201.31.5:8193/Axes/X/AbsolutePosition")]
|
||||
[InlineData("axis:5")] // the manual picker's old default
|
||||
public void Still_rejects_what_the_defect_authored(string input)
|
||||
=> FocasAddress.TryParse(input).ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// <c>F</c> is a valid PMC area letter, so the <c>FIXED:</c> prefix has to be tested before
|
||||
/// the PMC branch. Both forms must survive that ordering.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_fixed_prefix_does_not_shadow_the_pmc_F_area()
|
||||
{
|
||||
FocasAddress.TryParse("F20.3")!.Kind.ShouldBe(FocasAreaKind.Pmc);
|
||||
FocasAddress.TryParse("F20.3")!.PmcLetter.ShouldBe("F");
|
||||
FocasAddress.TryParse("FIXED:Program/Name")!.Kind.ShouldBe(FocasAreaKind.Fixed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("R100")]
|
||||
[InlineData("X0.0")]
|
||||
[InlineData("PARAM:1020")]
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:500")]
|
||||
public void The_pre_existing_forms_still_round_trip(string input)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldNotBe(FocasAreaKind.Fixed);
|
||||
parsed.Canonical.ShouldBe(input);
|
||||
}
|
||||
|
||||
/// <summary>The one place the <c>FIXED:</c> spelling is built, so emitter and parser cannot drift.</summary>
|
||||
[Fact]
|
||||
public void ForFixedPath_produces_what_TryParse_accepts()
|
||||
{
|
||||
var built = FocasAddress.ForFixedPath("Identity/SeriesNumber");
|
||||
|
||||
built.ShouldBe("FIXED:Identity/SeriesNumber");
|
||||
FocasAddress.TryParse(built)!.FixedPath.ShouldBe("Identity/SeriesNumber");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,66 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540. The previous edition of this file asserted
|
||||
/// <c>Build("axis", 5) == "axis:5"</c> for four "parameter groups" that are not FOCAS
|
||||
/// addressing spaces — it pinned the exact string the driver could not parse, which is why
|
||||
/// a fully-green suite coexisted with a picker whose default value was unusable. Every case
|
||||
/// here therefore asserts the address ROUND-TRIPS through the driver's parser, not merely
|
||||
/// that it has the expected shape.
|
||||
/// </summary>
|
||||
public sealed class FocasAddressBuilderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("axis", 5, "axis:5")]
|
||||
[InlineData("spindle", 0, "spindle:0")]
|
||||
[InlineData("program", 100, "program:100")]
|
||||
[InlineData("status", 1, "status:1")]
|
||||
public void Build_Canonical(string group, int parameterId, string expected)
|
||||
=> FocasAddressBuilder.Build(group, parameterId).ShouldBe(expected);
|
||||
[InlineData(FocasManualAddressKind.Pmc, "R", 100, null, "R100")]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "X", 0, 0, "X0.0")]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "F", 20, 3, "F20.3")]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "r", 100, null, "R100")] // letter is normalised
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, 1020, null, "PARAM:1020")]
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, 1815, 0, "PARAM:1815/0")]
|
||||
[InlineData(FocasManualAddressKind.Macro, null, 500, null, "MACRO:500")]
|
||||
[InlineData(FocasManualAddressKind.Macro, null, 500, 3, "MACRO:500")] // macros have no bit form
|
||||
public void Build_produces_an_address_the_driver_can_parse(
|
||||
FocasManualAddressKind kind, string? letter, int number, int? bit, string expected)
|
||||
{
|
||||
var built = FocasAddressBuilder.Build(kind, letter, number, bit);
|
||||
|
||||
built.ShouldBe(expected);
|
||||
FocasAddress.TryParse(built).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "Z", 10, null)] // not a PMC area letter
|
||||
[InlineData(FocasManualAddressKind.Pmc, "", 10, null)] // no area chosen
|
||||
[InlineData(FocasManualAddressKind.Pmc, "R", 10, 8)] // PMC bits are 0-7
|
||||
[InlineData(FocasManualAddressKind.Pmc, "R", -1, null)] // negative byte
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, 1815, 32)] // parameter bits are 0-31
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, -5, null)]
|
||||
public void Build_returns_empty_rather_than_an_unusable_address(
|
||||
FocasManualAddressKind kind, string? letter, int number, int? bit)
|
||||
=> FocasAddressBuilder.Build(kind, letter, number, bit).ShouldBeEmpty();
|
||||
|
||||
/// <summary>
|
||||
/// The contract that matters, stated over the whole offered surface rather than a sample:
|
||||
/// nothing the picker can compose is unreadable. Every PMC area the picker lists, at a
|
||||
/// whole-word address and at each legal bit.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_offered_pmc_area_composes_a_parseable_address()
|
||||
{
|
||||
foreach (var letter in FocasAddressBuilder.PmcLetters)
|
||||
{
|
||||
foreach (int? bit in new int?[] { null, 0, 7 })
|
||||
{
|
||||
var built = FocasAddressBuilder.Build(FocasManualAddressKind.Pmc, letter, 12, bit);
|
||||
|
||||
built.ShouldNotBeEmpty($"PMC area '{letter}' bit '{bit}' composed nothing");
|
||||
FocasAddress.TryParse(built).ShouldNotBeNull($"'{built}' does not parse");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,10 +74,36 @@ public sealed class FocasTagConfigModelTests
|
||||
new FocasTagConfigModel { Address = "" }.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_address_filled()
|
||||
[Theory]
|
||||
[InlineData("R100")]
|
||||
[InlineData("X0.0")]
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:500")]
|
||||
[InlineData("FIXED:Identity/SeriesNumber")]
|
||||
public void Validate_returns_null_for_every_address_form_the_driver_accepts(string address)
|
||||
=> new FocasTagConfigModel { Address = address }.Validate().ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540. <c>Validate</c> used to accept any non-blank string, so a browse full
|
||||
/// reference sailed through authoring, through the deploy gate, and was then skipped at
|
||||
/// driver init — where the only trace was a Debug-level log line nobody reads. The first
|
||||
/// case here is verbatim what the AdminUI showed for one of the 20 dead tags on the Makino.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("10.201.31.5:8193/Identity/SeriesNumber")]
|
||||
[InlineData("focas://10.201.31.5:8193/Axes/X/AbsolutePosition")]
|
||||
[InlineData("axis:5")]
|
||||
[InlineData("Z99")] // Z is not a PMC area letter
|
||||
[InlineData("PARAM:notanumber")]
|
||||
[InlineData("FIXED:")]
|
||||
public void Validate_rejects_an_address_the_driver_would_skip(string address)
|
||||
{
|
||||
new FocasTagConfigModel { Address = "R100" }.Validate().ShouldBeNull();
|
||||
var error = new FocasTagConfigModel { Address = address }.Validate();
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
// The operator has to be able to act on it, so the message names the legal forms.
|
||||
error.ShouldContain("PARAM:");
|
||||
error.ShouldContain("FIXED:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -107,6 +108,33 @@ public sealed class RawBrowseCommitMapperTests
|
||||
private static Dictionary<string, string> NativeTypeFields(string nativeType) =>
|
||||
new() { ["dataType"] = nativeType };
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540 — the commit path, judged by whether what it produces is authorable rather
|
||||
/// than by which key it lands under.
|
||||
/// <para>
|
||||
/// The mapper is not where that defect lived: it faithfully wrote the reference the FOCAS
|
||||
/// browser handed it, which was <c>{deviceHost}/{path}</c> and parses as nothing. The fix
|
||||
/// was in what the driver emits, so the check that keeps it fixed is that a leaf reference
|
||||
/// survives the commit AND passes the same validation the modal applies — a mapper test
|
||||
/// asserting only <c>o["address"] == reference</c> passed happily throughout.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="reference">A leaf reference of the kind the FOCAS browser emits.</param>
|
||||
[Theory]
|
||||
[InlineData("FIXED:Identity/SeriesNumber")]
|
||||
[InlineData("FIXED:Axes/X/AbsolutePosition")]
|
||||
[InlineData("FIXED:Spindle/S1/Load")]
|
||||
[InlineData("FIXED:Timers/PowerOnSeconds")]
|
||||
[InlineData("MACRO:3901")]
|
||||
public void A_committed_FOCAS_leaf_is_an_address_the_authoring_gate_accepts(string reference)
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.FOCAS, reference);
|
||||
|
||||
var model = FocasTagConfigModel.FromJson(json);
|
||||
model.Address.ShouldBe(reference);
|
||||
model.Validate().ShouldBeNull($"'{reference}' committed to a tag the driver would skip");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The committed <c>dataType</c> is the type the CONTROLLER reported, not the model default.
|
||||
/// AB CIP / TwinCAT / FOCAS decode the wire bytes by this field, so a browsed REAL left at the
|
||||
|
||||
Reference in New Issue
Block a user