fix(focas): refuse String on PMC/PARAM/MACRO in the factory, editor and CLI (R07:U8)

FocasDataType.String was authorable on any address, but no area read returns text: PMC
fell through to a byte read, PARAM to Int32, MACRO to a double, and the String node
received a boxed number at Good.

One rule, FocasTagDefinitionFactory.DescribeDataTypeMismatch, now backs every path:
- FromTagConfig rejects the pair, so the driver skips + reports it (BadNodeIdUnknown,
  never a Good number) and Inspect names the reason at deploy;
- FocasTagConfigModel.Validate refuses it on the typed fields;
- the AdminUI editor only offers String where it is readable and shows the rule's text
  inline (kept visible for a loaded blob that already carries it);
- the CLI read/subscribe/probe/write commands refuse it at the boundary.
String stays valid on FIXED: paths (Identity/Program name/mode text) and is ignored on
ALARM: tags; an unparseable address keeps its own diagnosis.

Razor change (FocasTagConfigEditor.razor) needs the Wave 4 live check.

Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:15:35 -04:00
parent 6423ed272f
commit cc140593a0
11 changed files with 214 additions and 5 deletions
@@ -33,6 +33,27 @@ public sealed class FocasCommandBaseValidationTests
/// <summary>Invokes option validation without interval.</summary>
public void InvokeValidateNoInterval() => ValidateOptions(intervalMs: null);
/// <summary>Invokes the address/data-type check.</summary>
public void InvokeValidateDataType(string address, FocasDataType type) => ValidateDataType(address, type);
}
/// <summary>
/// #740 / R07:U8 — <c>--type String</c> on a PMC / PARAM / MACRO address is refused at the CLI boundary
/// (the driver would skip the tag and the read would print BadNodeIdUnknown); a fixed-tree path keeps it.
/// </summary>
[Theory]
[InlineData("R100", FocasDataType.String, false)]
[InlineData("MACRO:500", FocasDataType.String, false)]
[InlineData("PARAM:6711", FocasDataType.String, false)]
[InlineData("R100", FocasDataType.Byte, true)]
[InlineData("FIXED:Identity/SeriesNumber", FocasDataType.String, true)]
public void Validate_data_type_against_address(string address, FocasDataType type, bool ok)
{
var sut = new Probe { CncHost = "host", IntervalMs = 1000 };
if (ok) Should.NotThrow(() => sut.InvokeValidateDataType(address, type));
else Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidateDataType(address, type))
.Message.ShouldContain("String");
}
/// <summary>Verifies that default options are accepted.</summary>
@@ -0,0 +1,104 @@
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>
/// #740 / R07:U8 — <see cref="FocasDataType.String"/> was authorable on any address, but no area read
/// produces a string: PMC fell through to a byte read, PARAM to an Int32, MACRO to a double, and a
/// String-typed node received a boxed number at Good. String is now refused on PMC / PARAM / MACRO by the
/// one shared check, everywhere a tag is authored or loaded; the fixed tree (Identity / Program name, …),
/// whose values really are strings, keeps it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasStringDataTypeTests
{
private const string Host = "focas://10.0.0.5:8193";
private static string Config(string address, string dataType) =>
$$"""{"address":"{{address}}","dataType":"{{dataType}}"}""";
/// <summary>String on an area read is refused by the mapper.</summary>
[Theory]
[InlineData("R100")]
[InlineData("X0.0")]
[InlineData("PARAM:6711")]
[InlineData("MACRO:500")]
public void Mapper_refuses_string_on_an_area_read(string address)
{
FocasTagDefinitionFactory.FromTagConfig(Config(address, "String"), "raw/T", out _).ShouldBeFalse();
FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, address).ShouldNotBeNull();
}
/// <summary>Positive control: numeric types on the same addresses, and String on the fixed tree, still map.</summary>
[Theory]
[InlineData("R100", "Byte")]
[InlineData("PARAM:6711", "Int32")]
[InlineData("MACRO:500", "Float64")]
[InlineData("FIXED:Identity/SeriesNumber", "String")]
[InlineData("FIXED:Program/Name", "String")]
public void Mapper_accepts_numeric_area_reads_and_string_fixed_tree_paths(string address, string dataType)
{
FocasTagDefinitionFactory.FromTagConfig(Config(address, dataType), "raw/T", out var def).ShouldBeTrue();
def.Address.ShouldBe(address);
}
/// <summary>An ALARM: tag has no value, so its data type is irrelevant and String is not refused.</summary>
[Fact]
public void Mapper_accepts_string_on_an_alarm_tag()
{
FocasTagDefinitionFactory.FromTagConfig(
"""{"address":"ALARM:*","dataType":"String","alarm":{"severity":500}}""", "raw/A", out _).ShouldBeTrue();
}
/// <summary>An unparseable address is left to the address check — it still draws THAT diagnosis.</summary>
[Fact]
public void Unparseable_address_keeps_its_own_diagnosis()
{
FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, "not-an-address").ShouldBeNull();
FocasTagDefinitionFactory.FromTagConfig(Config("not-an-address", "String"), "raw/T", out _).ShouldBeTrue();
}
/// <summary>The deploy-time inspector names the reason rather than falling to the generic backstop.</summary>
[Fact]
public void Inspect_names_the_string_refusal()
{
var warnings = FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "String"));
warnings.ShouldContain(w => w.Contains("String", StringComparison.Ordinal) && w.Contains("MACRO:500", StringComparison.Ordinal));
FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "Float64")).ShouldBeEmpty();
}
/// <summary>
/// At init a String area tag is skipped and reported (never served as a Good number); the numeric tag
/// beside it reads normally (positive control).
/// </summary>
[Fact]
public async Task Driver_skips_and_reports_a_string_area_tag()
{
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } },
};
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags =
[
new RawTagEntry("raw/Str", Config("R100", "String"), false, Host),
new RawTagEntry("raw/Num", Config("R100", "Byte"), false, Host),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-u8", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/Str", "raw/Num"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
snaps[0].Value.ShouldBeNull();
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[1].Value.ShouldBe((sbyte)7);
drv.GetSkippedTags()!.Sample.ShouldContain(s => s.RawPath == "raw/Str");
await drv.ShutdownAsync(CancellationToken.None);
}
}