5533de6281
The Modbus lane (#741 U3) gave the driver String[] and BitInRegister[] holding-register array encoders, and moved address resolution to ModbusTagAddressResolution (keys first, a bit region coerced to Bool, strict key reads), but DriverArrayWriteSupport still listed both array shapes read-only (follow-up F1), so a client write was BadNotWritable before it reached the driver. Modbus row now mirrors the driver end to end: - Keys shape only (region + address both present). A string-only addressString tag is resolved by the driver with its own family through a parser Core.Abstractions cannot reference, so it stays read-only (pinned: the conservative direction). - Strict reads as the resolver: address an integer 0-65535; a present region / dataType / byteOrder / stringByteOrder string must name a member. - Coils: any dataType (coerced to Bool), <= 1968 coils (FC15). - HoldingRegisters (FC16, never Bool): numeric, String ((stringLength+1)/2 registers, stringLength >= 1), BitInRegister (1 register, bitIndex 0-15, span ending <= 65535), all within maxRegistersPerWrite. FC23 is opt-in and never changes the answer. - Elements convert (ScadaBridge int[] -> Int16 stays writable); only a String / DateTime node over a numeric or bit element is left out, since such an element never converts. Tests: the parity test's PendingF1 exemption and its guard are gone; the parity matrix is now region x element type x node type against the real WriteAsync with a spec-conformant device (FC15 > 1968 refused), plus shape edges at every cap boundary (numeric, String, BitInRegister, coil), strict key reads, FC23 on/off, string-only, the ScadaBridge int[] shape and U3 wire bytes. Core.Abstractions row tests and a composer test (String[] and BitInRegister[] writable; over-cap, no-stringLength and string-only read-only). Docs: table XML doc, Uns.md, modbus-addressing.md, drivers/Modbus.md, data-plane rules. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
478 lines
29 KiB
C#
478 lines
29 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// v3 Batch 4 (B4-WP1) — the composer un-darkens BOTH subtrees. Verifies the Raw subtree
|
|
/// (Folder→Driver→Device→TagGroup container nodes + raw-tag Variables keyed <c>s=<RawPath></c>,
|
|
/// realm <see cref="AddressSpaceRealm.Raw"/>) and the UNS subtree (one Variable per
|
|
/// <see cref="UnsTagReference"/> keyed <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c>,
|
|
/// realm <see cref="AddressSpaceRealm.Uns"/>, carrying its backing RawPath for the Organizes ref +
|
|
/// fan-out). Native-alarm intents attach at the RAW tag and carry the referencing equipment paths.
|
|
/// </summary>
|
|
public sealed class AddressSpaceComposerDualNamespaceTests
|
|
{
|
|
// A raw chain: RawFolder "Plant" → Driver "Modbus1" → Device "PLC-A" → Group "Motors" → tags.
|
|
// Speed sits under the group; Status directly under the device; Levels is an array tag.
|
|
private static AddressSpaceComposition ComposeFullFixture()
|
|
{
|
|
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
|
|
var driver = new DriverInstance
|
|
{
|
|
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
|
|
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
|
|
};
|
|
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
|
|
var group = new TagGroup { TagGroupId = "g1", DeviceId = "dev-1", Name = "Motors" };
|
|
|
|
var speed = new Tag
|
|
{
|
|
TagId = "t-speed", DeviceId = "dev-1", TagGroupId = "g1", Name = "Speed",
|
|
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite,
|
|
TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"speed_hist\"," +
|
|
"\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":700}}",
|
|
};
|
|
var status = new Tag
|
|
{
|
|
TagId = "t-status", DeviceId = "dev-1", Name = "Status",
|
|
DataType = "Boolean", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
|
};
|
|
var levels = new Tag
|
|
{
|
|
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels",
|
|
DataType = "Int32", AccessLevel = TagAccessLevel.Read,
|
|
TagConfig = "{\"isArray\":true,\"arrayLength\":8}",
|
|
};
|
|
|
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
|
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
|
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
|
|
|
|
var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" };
|
|
var refStatus = new UnsTagReference
|
|
{
|
|
UnsTagReferenceId = "ref-status", EquipmentId = "eq-1", TagId = "t-status",
|
|
DisplayNameOverride = "MachineStatus",
|
|
};
|
|
|
|
return AddressSpaceComposer.Compose(
|
|
new[] { area }, new[] { line }, new[] { equip },
|
|
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
|
unsTagReferences: new[] { refSpeed, refStatus },
|
|
tags: new[] { speed, status, levels },
|
|
rawFolders: new[] { folder },
|
|
devices: new[] { device },
|
|
tagGroups: new[] { group });
|
|
}
|
|
|
|
/// <summary>The Raw subtree emits Folder/Driver/Device/TagGroup container nodes keyed by RawPath, each
|
|
/// realm=Raw, with parents-before-children ordering + correct parent NodeIds.</summary>
|
|
[Fact]
|
|
public void Raw_container_nodes_materialise_with_rawpath_nodeids_and_parents()
|
|
{
|
|
var c = ComposeFullFixture();
|
|
|
|
c.RawContainers.ShouldAllBe(n => n.Realm == AddressSpaceRealm.Raw);
|
|
|
|
var folder = c.RawContainers.Single(n => n.Kind == RawNodeKind.Folder);
|
|
folder.NodeId.ShouldBe("Plant");
|
|
folder.ParentNodeId.ShouldBeNull(); // cluster root
|
|
folder.DisplayName.ShouldBe("Plant");
|
|
|
|
var driver = c.RawContainers.Single(n => n.Kind == RawNodeKind.Driver);
|
|
driver.NodeId.ShouldBe("Plant/Modbus1");
|
|
driver.ParentNodeId.ShouldBe("Plant");
|
|
|
|
var device = c.RawContainers.Single(n => n.Kind == RawNodeKind.Device);
|
|
device.NodeId.ShouldBe("Plant/Modbus1/PLC-A");
|
|
device.ParentNodeId.ShouldBe("Plant/Modbus1");
|
|
|
|
var group = c.RawContainers.Single(n => n.Kind == RawNodeKind.TagGroup);
|
|
group.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors");
|
|
group.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A");
|
|
|
|
// Sorted by NodeId ordinal ⇒ a parent always precedes each of its children.
|
|
c.RawContainers.Select(n => n.NodeId)
|
|
.ShouldBe(new[] { "Plant", "Plant/Modbus1", "Plant/Modbus1/PLC-A", "Plant/Modbus1/PLC-A/Motors" });
|
|
}
|
|
|
|
/// <summary>Raw tags are Variables keyed <c>s=<RawPath></c> (realm=Raw), carrying DataType /
|
|
/// writable / historize + historian tagname / array shape, and hang under their group (or device).</summary>
|
|
[Fact]
|
|
public void Raw_tags_materialise_as_variables_keyed_by_rawpath_with_carried_attributes()
|
|
{
|
|
var c = ComposeFullFixture();
|
|
|
|
c.RawTags.ShouldAllBe(t => t.Realm == AddressSpaceRealm.Raw);
|
|
|
|
var speed = c.RawTags.Single(t => t.TagId == "t-speed");
|
|
speed.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed");
|
|
speed.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors");
|
|
speed.DriverInstanceId.ShouldBe("drv-modbus");
|
|
speed.DataType.ShouldBe("Float");
|
|
speed.Writable.ShouldBeTrue();
|
|
speed.IsHistorized.ShouldBeTrue();
|
|
speed.HistorianTagname.ShouldBe("speed_hist");
|
|
|
|
var status = c.RawTags.Single(t => t.TagId == "t-status");
|
|
status.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Status"); // no group ⇒ under the device
|
|
status.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A");
|
|
status.Writable.ShouldBeFalse();
|
|
status.IsHistorized.ShouldBeFalse();
|
|
|
|
var levels = c.RawTags.Single(t => t.TagId == "t-levels");
|
|
levels.IsArray.ShouldBeTrue();
|
|
levels.ArrayLength.ShouldBe(8u);
|
|
}
|
|
|
|
/// <summary>A native-alarm intent attaches at the RAW tag (ConditionId = RawPath) and carries the list
|
|
/// of referencing equipment UNS folder paths — one alarm at the raw tag, not one per equipment.</summary>
|
|
[Fact]
|
|
public void Native_alarm_attaches_at_raw_tag_with_referencing_equipment_paths()
|
|
{
|
|
var c = ComposeFullFixture();
|
|
|
|
var speed = c.RawTags.Single(t => t.TagId == "t-speed");
|
|
speed.Alarm.ShouldNotBeNull();
|
|
speed.Alarm!.AlarmType.ShouldBe("LimitAlarm");
|
|
speed.Alarm.Severity.ShouldBe(700);
|
|
speed.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" });
|
|
|
|
// A non-alarm tag carries no condition + its own referencing-equipment list.
|
|
var status = c.RawTags.Single(t => t.TagId == "t-status");
|
|
status.Alarm.ShouldBeNull();
|
|
status.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" });
|
|
|
|
// An unreferenced tag has an empty referencing-equipment set.
|
|
var levels = c.RawTags.Single(t => t.TagId == "t-levels");
|
|
levels.ReferencingEquipmentPaths.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Each UnsTagReference emits a UNS Variable keyed
|
|
/// <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c> (realm=Uns), carrying its backing
|
|
/// RawPath (the Organizes target + fan-out) and inheriting DataType + writable from the raw tag. Effective
|
|
/// name = DisplayNameOverride else the backing raw tag's Name.</summary>
|
|
[Fact]
|
|
public void Uns_reference_variables_carry_backing_rawpath_and_inherit_type_and_access()
|
|
{
|
|
var c = ComposeFullFixture();
|
|
|
|
c.UnsReferenceVariables.ShouldAllBe(v => v.Realm == AddressSpaceRealm.Uns);
|
|
|
|
var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed");
|
|
speedRef.EffectiveName.ShouldBe("Speed"); // no override ⇒ backing raw tag Name
|
|
speedRef.NodeId.ShouldBe("filling/line-1/station-1/Speed");
|
|
speedRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); // Organizes UNS→Raw + fan-out
|
|
speedRef.DataType.ShouldBe("Float");
|
|
speedRef.Writable.ShouldBeTrue(); // inherited from the ReadWrite raw tag
|
|
|
|
var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status");
|
|
statusRef.EffectiveName.ShouldBe("MachineStatus"); // display-name override wins
|
|
statusRef.NodeId.ShouldBe("filling/line-1/station-1/MachineStatus");
|
|
statusRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Status");
|
|
statusRef.DataType.ShouldBe("Boolean");
|
|
statusRef.Writable.ShouldBeFalse(); // inherited from the Read raw tag
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-15 surgical-deploys review — a UNS reference also carries its backing tag's HISTORIAN
|
|
/// intent + ARRAY shape (<c>Backing{IsHistorized,HistorianTagname,IsArray,ArrayLength}</c>). They are
|
|
/// on the record so a raw-side edit to any of them makes the reference record unequal, producing the
|
|
/// coincident <c>UnsReferenceDelta</c> that repairs the mirror node; without them the UNS node kept the
|
|
/// old historian tagname / scalar shape after a surgical raw edit.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Uns_reference_variables_carry_backing_historian_intent_and_array_shape()
|
|
{
|
|
var c = ComposeFullFixture();
|
|
|
|
// Backing tag historized WITH an override → both fields ride the reference verbatim.
|
|
var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed");
|
|
speedRef.BackingIsHistorized.ShouldBeTrue();
|
|
speedRef.BackingHistorianTagname.ShouldBe("speed_hist");
|
|
speedRef.BackingIsArray.ShouldBeFalse();
|
|
speedRef.BackingArrayLength.ShouldBeNull();
|
|
|
|
// Backing tag neither historized nor array → the defaults.
|
|
var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status");
|
|
statusRef.BackingIsHistorized.ShouldBeFalse();
|
|
statusRef.BackingHistorianTagname.ShouldBeNull();
|
|
statusRef.BackingIsArray.ShouldBeFalse();
|
|
statusRef.BackingArrayLength.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>The array + historize-without-override branches of the same inheritance: an ARRAY backing tag
|
|
/// projects its shape onto the reference (so the UNS mirror is created as an array, and therefore
|
|
/// read-only), and a historized tag with a BLANK override leaves <c>BackingHistorianTagname</c> null — the
|
|
/// applier resolves that to the BackingRawPath, exactly as the raw side defaults to its NodeId.</summary>
|
|
[Fact]
|
|
public void Uns_reference_variables_carry_array_shape_and_blank_historian_override()
|
|
{
|
|
var driver = new DriverInstance
|
|
{
|
|
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = null,
|
|
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
|
|
};
|
|
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
|
|
var levels = new Tag
|
|
{
|
|
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int32",
|
|
AccessLevel = TagAccessLevel.ReadWrite,
|
|
TagConfig = "{\"isArray\":true,\"arrayLength\":8,\"isHistorized\":true}",
|
|
};
|
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
|
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
|
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
|
|
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
|
|
|
|
var c = AddressSpaceComposer.Compose(
|
|
new[] { area }, new[] { line }, new[] { equip },
|
|
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
|
unsTagReferences: new[] { reference }, tags: new[] { levels },
|
|
rawFolders: Array.Empty<RawFolder>(), devices: new[] { device });
|
|
|
|
var levelsRef = c.UnsReferenceVariables.ShouldHaveSingleItem();
|
|
levelsRef.BackingIsArray.ShouldBeTrue();
|
|
levelsRef.BackingArrayLength.ShouldBe(8u);
|
|
levelsRef.BackingIsHistorized.ShouldBeTrue();
|
|
levelsRef.BackingHistorianTagname.ShouldBeNull(); // blank override ⇒ resolved to BackingRawPath later
|
|
}
|
|
|
|
/// <summary>A composition with no raw/UNS inputs leaves all three new subtree sets empty (the legacy
|
|
/// convenience overloads + earlier callers keep working).</summary>
|
|
[Fact]
|
|
public void No_raw_or_uns_inputs_leaves_both_subtrees_empty()
|
|
{
|
|
var c = AddressSpaceComposer.Compose(
|
|
equipment: Array.Empty<Equipment>(),
|
|
driverInstances: Array.Empty<DriverInstance>(),
|
|
scriptedAlarms: Array.Empty<ScriptedAlarm>());
|
|
|
|
c.RawContainers.ShouldBeEmpty();
|
|
c.RawTags.ShouldBeEmpty();
|
|
c.UnsReferenceVariables.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>The Raw + UNS emit is deterministic — repeated calls produce element-identical output
|
|
/// (RawTagPlan/UnsReferenceVariable compare by value, so ShouldBe is a content comparison).</summary>
|
|
[Fact]
|
|
public void Dual_subtree_emit_is_deterministic()
|
|
{
|
|
var a = ComposeFullFixture();
|
|
var b = ComposeFullFixture();
|
|
|
|
a.RawContainers.ShouldBe(b.RawContainers);
|
|
a.RawTags.ShouldBe(b.RawTags);
|
|
a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables);
|
|
}
|
|
|
|
/// <summary>Gitea #610: <c>ArrayWritable</c> is derived per OWNING DRIVER at compose time, and the UNS mirror
|
|
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; an AbCip BOOL array (bit-packed
|
|
/// DWORDs, which AbCip's array encoder refuses) is not; a scalar is never flagged.</summary>
|
|
[Fact]
|
|
public void Array_write_capability_follows_the_owning_driver_and_reaches_the_uns_mirror()
|
|
{
|
|
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
|
var s7 = new DriverInstance { DriverInstanceId = "drv-s7", ClusterId = "c1", RawFolderId = "f", Name = "S7a", DriverType = "S7", DriverConfig = "{}" };
|
|
var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "f", Name = "Ab1", DriverType = "AbCip", DriverConfig = "{}" };
|
|
var d1 = new Device { DeviceId = "d1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" };
|
|
var d2 = new Device { DeviceId = "d2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" };
|
|
const string s7Array = "{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":4}";
|
|
var tags = new[]
|
|
{
|
|
new Tag { TagId = "t1", DeviceId = "d1", Name = "Levels", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = s7Array },
|
|
new Tag { TagId = "t2", DeviceId = "d1", Name = "Level", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"address\":\"DB1.DBW8\",\"dataType\":\"Int16\"}" },
|
|
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Boolean", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}" },
|
|
};
|
|
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
|
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
|
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
|
var refs = new[]
|
|
{
|
|
new UnsTagReference { UnsTagReferenceId = "r1", EquipmentId = "e", TagId = "t1" },
|
|
new UnsTagReference { UnsTagReferenceId = "r3", EquipmentId = "e", TagId = "t3" },
|
|
};
|
|
|
|
var c = AddressSpaceComposer.Compose(
|
|
new[] { area }, new[] { line }, new[] { equip }, new[] { s7, ab }, Array.Empty<ScriptedAlarm>(),
|
|
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { d1, d2 });
|
|
|
|
c.RawTags.Single(t => t.Name == "Levels").ArrayWritable.ShouldBeTrue();
|
|
c.RawTags.Single(t => t.Name == "Level").ArrayWritable.ShouldBeFalse(); // scalar: never flagged
|
|
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no BOOL-array encoder
|
|
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
|
|
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
|
|
|
|
// #610 review R6 MINOR-1: the array-on-scalar refusal follows the owning DRIVER (S7 yes, AbCip no — AbCip
|
|
// keeps pre-#610 dispatch), is not gated on the tag's shape, and reaches the UNS mirror.
|
|
c.RawTags.Single(t => t.Name == "Level").ScalarRefusesArrayValue.ShouldBeTrue();
|
|
c.RawTags.Single(t => t.Name == "Levels").ScalarRefusesArrayValue.ShouldBeTrue();
|
|
c.RawTags.Single(t => t.Name == "Counts").ScalarRefusesArrayValue.ShouldBeFalse();
|
|
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingScalarRefusesArrayValue.ShouldBeTrue();
|
|
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingScalarRefusesArrayValue.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>#610 review MINOR-1 — the composer hands the table the owning driver's <c>DriverConfig</c> AND the
|
|
/// tag's own device's <c>DeviceConfig</c>, so a Modbus write cap configured at either level keeps an array that
|
|
/// can never fit read-only — on the raw node and its UNS mirror alike. The same tag under an uncapped device is
|
|
/// the positive control.</summary>
|
|
[Fact]
|
|
public void Array_write_capability_honours_a_modbus_write_cap_from_driver_or_device_config()
|
|
{
|
|
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
|
var mb = new DriverInstance { DriverInstanceId = "drv-mb", ClusterId = "c1", RawFolderId = "f", Name = "Mb", DriverType = "Modbus", DriverConfig = "{\"MaxRegistersPerWrite\":100}" };
|
|
var open = new Device { DeviceId = "d-open", DriverInstanceId = "drv-mb", Name = "open", DeviceConfig = "{}" };
|
|
var capped = new Device { DeviceId = "d-capped", DriverInstanceId = "drv-mb", Name = "capped", DeviceConfig = "{\"maxRegistersPerWrite\":8}" };
|
|
static Tag Arr(string id, string device, int length) => new()
|
|
{
|
|
TagId = id, DeviceId = device, Name = id, DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite,
|
|
TagConfig = $"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":{length}}}",
|
|
};
|
|
var tags = new[] { Arr("fits", "d-open", 100), Arr("overDriverCap", "d-open", 101), Arr("overDeviceCap", "d-capped", 10) };
|
|
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
|
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
|
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
|
var refs = new[] { new UnsTagReference { UnsTagReferenceId = "r", EquipmentId = "e", TagId = "overDeviceCap" } };
|
|
|
|
var c = AddressSpaceComposer.Compose(
|
|
new[] { area }, new[] { line }, new[] { equip }, new[] { mb }, Array.Empty<ScriptedAlarm>(),
|
|
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { open, capped });
|
|
|
|
c.RawTags.Single(t => t.Name == "fits").ArrayWritable.ShouldBeTrue();
|
|
c.RawTags.Single(t => t.Name == "overDriverCap").ArrayWritable.ShouldBeFalse();
|
|
c.RawTags.Single(t => t.Name == "overDeviceCap").ArrayWritable.ShouldBeFalse();
|
|
c.UnsReferenceVariables.Single().BackingArrayWritable.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// #737 / #738 / #739 (review 07 U10 / U9): AbCip, AbLegacy and TwinCAT encode whole-array writes, so an
|
|
/// authored-<c>ReadWrite</c> array tag on each now materialises WRITABLE — raw node and UNS mirror alike — with
|
|
/// the ScadaBridge-shaped Int16 node type. An array already deployed read-only turns writable on the next deploy
|
|
/// through <c>ArrayWritable</c>, which rides the surgical whitelist (pinned in the classifier tests). A shape
|
|
/// each encoder refuses (an AbCip BOOL array, an AbLegacy Bit array, an AbLegacy Int array on an F file, a
|
|
/// TwinCAT BOOL bit-in-word path) stays read-only on the SAME drivers — the negative control.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AbCip_AbLegacy_and_TwinCAT_arrays_materialise_writable_and_refused_shapes_stay_read_only()
|
|
{
|
|
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
|
var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "f", Name = "Ab", DriverType = "AbCip", DriverConfig = "{}" };
|
|
var slc = new DriverInstance { DriverInstanceId = "drv-slc", ClusterId = "c1", RawFolderId = "f", Name = "Slc", DriverType = "AbLegacy", DriverConfig = "{}" };
|
|
var tc = new DriverInstance { DriverInstanceId = "drv-tc", ClusterId = "c1", RawFolderId = "f", Name = "Tc", DriverType = "TwinCAT", DriverConfig = "{}" };
|
|
var d1 = new Device { DeviceId = "d1", DriverInstanceId = "drv-ab", Name = "plc", DeviceConfig = "{}" };
|
|
var d2 = new Device { DeviceId = "d2", DriverInstanceId = "drv-slc", Name = "slc", DeviceConfig = "{\"plcFamily\":\"Slc500\"}" };
|
|
var d3 = new Device { DeviceId = "d3", DriverInstanceId = "drv-tc", Name = "ads", DeviceConfig = "{}" };
|
|
static Tag Arr(string id, string device, string dataType, string tagConfig) => new()
|
|
{
|
|
TagId = id, DeviceId = device, Name = id, DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite,
|
|
TagConfig = tagConfig,
|
|
};
|
|
var tags = new[]
|
|
{
|
|
Arr("abInts", "d1", "Int16", "{\"tagPath\":\"Ints\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
|
Arr("abBools", "d1", "Boolean", "{\"tagPath\":\"Bools\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}"),
|
|
Arr("slcInts", "d2", "Int16", "{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
|
Arr("slcBits", "d2", "Boolean", "{\"address\":\"B3:0\",\"dataType\":\"Bit\",\"isArray\":true,\"arrayLength\":4}"),
|
|
Arr("slcWrongFile", "d2", "Int16", "{\"address\":\"F8:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
|
Arr("tcInts", "d3", "Int16", "{\"symbolPath\":\"MAIN.Ints\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
|
Arr("tcBit", "d3", "Boolean", "{\"symbolPath\":\"MAIN.Word.3\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}"),
|
|
};
|
|
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
|
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
|
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
|
var refs = new[]
|
|
{
|
|
new UnsTagReference { UnsTagReferenceId = "r-ab", EquipmentId = "e", TagId = "abInts" },
|
|
new UnsTagReference { UnsTagReferenceId = "r-slc", EquipmentId = "e", TagId = "slcInts" },
|
|
new UnsTagReference { UnsTagReferenceId = "r-tc", EquipmentId = "e", TagId = "tcInts" },
|
|
new UnsTagReference { UnsTagReferenceId = "r-bits", EquipmentId = "e", TagId = "slcBits" },
|
|
};
|
|
|
|
var c = AddressSpaceComposer.Compose(
|
|
new[] { area }, new[] { line }, new[] { equip }, new[] { ab, slc, tc }, Array.Empty<ScriptedAlarm>(),
|
|
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { d1, d2, d3 });
|
|
|
|
foreach (var writable in new[] { "abInts", "slcInts", "tcInts" })
|
|
{
|
|
var plan = c.RawTags.Single(t => t.Name == writable);
|
|
plan.ArrayWritable.ShouldBeTrue(writable);
|
|
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeTrue(writable);
|
|
}
|
|
foreach (var refused in new[] { "abBools", "slcBits", "slcWrongFile", "tcBit" })
|
|
{
|
|
var plan = c.RawTags.Single(t => t.Name == refused);
|
|
plan.IsArray.ShouldBeTrue(refused); // still an array node — just not a writable one
|
|
plan.ArrayWritable.ShouldBeFalse(refused);
|
|
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeFalse(refused);
|
|
}
|
|
|
|
foreach (var id in new[] { "r-ab", "r-slc", "r-tc" })
|
|
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
|
|
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-bits")).ShouldBeFalse();
|
|
}
|
|
/// <summary>
|
|
/// #741 (Modbus lane U3, follow-up F1): the Modbus driver encodes String[] and BitInRegister[] holding-register
|
|
/// arrays, so an authored-<c>ReadWrite</c> array of each materialises WRITABLE — raw node and UNS mirror alike. A
|
|
/// Coils array authored with a non-Bool type (the resolver coerces a bit region to Bool) is writable too. Shapes
|
|
/// the driver refuses stay read-only on the SAME driver: a BitInRegister array over the FC16 cap, a String array
|
|
/// with no <c>stringLength</c>, and a string-only (<c>addressString</c>) array whose family the composer cannot
|
|
/// know — the negative controls.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Modbus_string_and_bit_in_register_arrays_materialise_writable_and_refused_shapes_stay_read_only()
|
|
{
|
|
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
|
var mb = new DriverInstance { DriverInstanceId = "drv-mb", ClusterId = "c1", RawFolderId = "f", Name = "Mb", DriverType = "Modbus", DriverConfig = "{}" };
|
|
var dev = new Device { DeviceId = "d1", DriverInstanceId = "drv-mb", Name = "plc", DeviceConfig = "{}" };
|
|
static Tag Arr(string id, string dataType, string tagConfig) => new()
|
|
{
|
|
TagId = id, DeviceId = "d1", Name = id, DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite,
|
|
TagConfig = tagConfig,
|
|
};
|
|
var tags = new[]
|
|
{
|
|
Arr("names", "String", "{\"region\":\"HoldingRegisters\",\"address\":100,\"dataType\":\"String\",\"stringLength\":8,\"isArray\":true,\"arrayLength\":4}"),
|
|
Arr("flags", "Boolean", "{\"region\":\"HoldingRegisters\",\"address\":200,\"dataType\":\"BitInRegister\",\"bitIndex\":3,\"isArray\":true,\"arrayLength\":8}"),
|
|
Arr("legacyCoils", "Int16", "{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":8}"),
|
|
Arr("overCap", "Boolean", "{\"region\":\"HoldingRegisters\",\"address\":300,\"dataType\":\"BitInRegister\",\"isArray\":true,\"arrayLength\":124}"),
|
|
Arr("noLength", "String", "{\"region\":\"HoldingRegisters\",\"address\":400,\"dataType\":\"String\",\"isArray\":true,\"arrayLength\":4}"),
|
|
Arr("stringOnly", "Int16", "{\"addressString\":\"40501:S::4\",\"isArray\":true,\"arrayLength\":4}"),
|
|
};
|
|
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
|
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
|
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
|
var refs = new[]
|
|
{
|
|
new UnsTagReference { UnsTagReferenceId = "r-names", EquipmentId = "e", TagId = "names" },
|
|
new UnsTagReference { UnsTagReferenceId = "r-flags", EquipmentId = "e", TagId = "flags" },
|
|
new UnsTagReference { UnsTagReferenceId = "r-over", EquipmentId = "e", TagId = "overCap" },
|
|
};
|
|
|
|
var c = AddressSpaceComposer.Compose(
|
|
new[] { area }, new[] { line }, new[] { equip }, new[] { mb }, Array.Empty<ScriptedAlarm>(),
|
|
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { dev });
|
|
|
|
foreach (var writable in new[] { "names", "flags", "legacyCoils" })
|
|
{
|
|
var plan = c.RawTags.Single(t => t.Name == writable);
|
|
plan.ArrayWritable.ShouldBeTrue(writable);
|
|
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeTrue(writable);
|
|
}
|
|
foreach (var refused in new[] { "overCap", "noLength", "stringOnly" })
|
|
{
|
|
var plan = c.RawTags.Single(t => t.Name == refused);
|
|
plan.IsArray.ShouldBeTrue(refused); // still an array node — just not a writable one
|
|
plan.ArrayWritable.ShouldBeFalse(refused);
|
|
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeFalse(refused);
|
|
}
|
|
|
|
foreach (var id in new[] { "r-names", "r-flags" })
|
|
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
|
|
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-over")).ShouldBeFalse();
|
|
}
|
|
}
|