3a312ffdd1
v2-ci / unit-tests (push) Has been cancelled
A notifier folder read one event source, its NodeId identifier (a RawPath or an EquipmentId). AVEVA stores a Galaxy object's A&E history under the object's tag_name (Source_Object), so AVEVA's own alarm and event history reached no production folder. Its lower-case property keys were also missed, so every row read Severity 1 with no ConditionName. Sources. A folder's event history now reads its own source plus the Galaxy object of every native condition that fires on it live: those parented under it and those WireAlarmNotifiers wired to it. Live and history use the same membership. The object comes from the condition's Galaxy alarm reference, its raw tag's attributeRef, up to the first '.': - The composer reads it with the driver's own parser (GalaxyAttributeRef, shared with GalaxyDriver), only for a GalaxyMxGateway tag with an alarm (RawTagPlan.NativeAlarmReference, part of plan equality). - The applier binds it after materialising the condition (IOpcUaAddressSpaceSink.BindNativeAlarmHistory, forwarded by the deferred sink). A rebuild or the condition's removal forgets it. Each object is read with the #882 rule: its own events and <Object>.<Attribute> rows, never a prefix sibling. Fan-out. historiangw's ReadEvents filter is a single predicate, so there is one request per source; the wire contract is unchanged. EventSourceFanOut merges the reads as one source: - order is (time, source position, arrival), reversed for a reverse read; ties are never re-sorted by EventId, so the boundary skip stays stable; - duplicates by EventId are dropped and the merge is cut to the cap; - a truncated source bounds the page at the time it stopped. Paging, continuation points (bound to the whole source set), the walked-back reverse read and the tie-cluster over-fetch are unchanged. A folder with one source is read exactly as before. ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied folder reads nothing and fails closed. Mapping (EventMapper, property names now case-insensitive): - Severity: an AVEVA row's priority goes through GalaxyAlarmPriority, the table live Galaxy alarms use (MxAccessSeverityMapper now delegates to it), so history matches live. Otherwise severity (OtOpcUa's own rows, which AVEVA stores lower-cased), clamped. AVEVA's own 1-4 severity is ignored. The table ranks a higher priority as more severe, which inverts AVEVA's convention: filed as #887. - An AVEVA row whose source_conditionvariable names a native condition that fires on the folder takes that condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname or the condition variable's attribute. - alarm_id/alarmid map to AlarmId. Tests: - GalaxyEquipmentEventHistoryWireTests (4, real SDK session, production Gateway backend, AVEVA-shaped fake gateway): equipment EqA backed by objects Oven_007 + OvenDoor_07. Covers object and attribute events from both objects, the prefix sibling excluded, cross-object and in-object ties, forward paging at 1-4 a page, reverse whole and paged, SourceNode/ConditionName/Severity, the raw device folder, ACL deny with no backend call plus a granted positive control, and a rebuild forgetting the bindings. - EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and an applier ordering test. - Mutation-checked, 17 mutants, all killed. Covered: parent-only membership, no scope, a case-sensitive condition match, no condition rewrite, no truncation bound, EventId tie order, no sort, no cap, no stop at the bound, unreversed source order, no dedupe, no '.' boundary, priority ignored, case-sensitive keys, no applier bind, no deferred forward, and a composer that ignores the driver type. The forget-on-rematerialise guard survives as belt-and-braces: the rebuild clear covers it. - Live, read-only, no WinRM, vd03 HistorianGateway ea9f21c: equipment folder EQ-886-ZipsenLeft (objects DevWinPlatform + Left_001). It read 0 events before and 92 now, equal to the unfiltered read's rows for both objects. 1/7-event pages and reverse whole/5-event pages agree, and 73 rows resolve to their live condition. diff_runs vs the #882 run: 221/226 identical. The 5 that differ are expected: DevWinPlatform Severity 1 -> 750/1000, and #881 capabilities. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
514 lines
32 KiB
C#
514 lines
32 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>Gitea #886: a Galaxy tag that carries an alarm names its AVEVA alarm reference (its <c>attributeRef</c>), so
|
|
/// the folders its condition fires on can read the history AVEVA stores under that object. A Galaxy value tag, and an
|
|
/// alarm tag of any other driver (even with an <c>attributeRef</c>-shaped key), name none.</summary>
|
|
[Fact]
|
|
public void A_galaxy_alarm_tag_names_its_aveva_alarm_reference_and_no_other_tag_does()
|
|
{
|
|
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
|
var gx = new DriverInstance { DriverInstanceId = "drv-gx", ClusterId = "c1", RawFolderId = "f", Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}" };
|
|
var mb = new DriverInstance { DriverInstanceId = "drv-mb", ClusterId = "c1", RawFolderId = "f", Name = "Mb", DriverType = "Modbus", DriverConfig = "{}" };
|
|
var oven = new Device { DeviceId = "d-gx", DriverInstanceId = "drv-gx", Name = "Oven7", DeviceConfig = "{}" };
|
|
var plc = new Device { DeviceId = "d-mb", DriverInstanceId = "drv-mb", Name = "plc", DeviceConfig = "{}" };
|
|
const string alarm = "\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":700}";
|
|
var tags = new[]
|
|
{
|
|
new Tag { TagId = "t-hi", DeviceId = "d-gx", Name = "TempHi", DataType = "Boolean", AccessLevel = TagAccessLevel.Read,
|
|
TagConfig = $"{{\"attributeRef\":\"Oven_007.TempHiAlarm\",{alarm}}}" },
|
|
new Tag { TagId = "t-temp", DeviceId = "d-gx", Name = "Temp", DataType = "Float", AccessLevel = TagAccessLevel.Read,
|
|
TagConfig = "{\"attributeRef\":\"Oven_007.Temp\"}" },
|
|
new Tag { TagId = "t-mb", DeviceId = "d-mb", Name = "Hi", DataType = "Boolean", AccessLevel = TagAccessLevel.Read,
|
|
TagConfig = $"{{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\",\"attributeRef\":\"Oven_007.X\",{alarm}}}" },
|
|
};
|
|
|
|
var c = AddressSpaceComposer.Compose(
|
|
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(), new[] { gx, mb }, Array.Empty<ScriptedAlarm>(),
|
|
tags: tags, rawFolders: new[] { folder }, devices: new[] { oven, plc });
|
|
|
|
c.RawTags.Single(t => t.TagId == "t-hi").NativeAlarmReference.ShouldBe("Oven_007.TempHiAlarm");
|
|
c.RawTags.Single(t => t.TagId == "t-temp").NativeAlarmReference.ShouldBeNull();
|
|
c.RawTags.Single(t => t.TagId == "t-mb").Alarm.ShouldNotBeNull();
|
|
c.RawTags.Single(t => t.TagId == "t-mb").NativeAlarmReference.ShouldBeNull();
|
|
|
|
// The reference is part of the plan's identity: a re-pointed attributeRef is a change, not a no-op redeploy.
|
|
var hi = c.RawTags.Single(t => t.TagId == "t-hi");
|
|
hi.Equals(hi with { NativeAlarmReference = "Oven_008.TempHiAlarm" }).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();
|
|
}
|
|
}
|