diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 5075db4..eb11b98 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -1025,10 +1025,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 }) { var udtFolder = deviceFolder.Folder(tag.Name, tag.Name); + // PR abcip-2.6 — AOI-aware fan-out. When any member carries a non-Local + // AoiQualifier the tag is treated as an AOI instance: Input / Output / InOut + // members get grouped under sub-folders (Inputs/, Outputs/, InOut/) so the + // browse tree visually matches Studio 5000's AOI parameter tabs. Plain UDT + // tags (every member Local) retain the pre-2.6 flat layout under the parent + // folder so existing browse paths stay stable. + var hasDirectional = tag.Members.Any(m => m.AoiQualifier != AoiQualifier.Local); + IAddressSpaceBuilder? inputsFolder = null; + IAddressSpaceBuilder? outputsFolder = null; + IAddressSpaceBuilder? inOutFolder = null; foreach (var member in tag.Members) { + var parentFolder = udtFolder; + if (hasDirectional) + { + parentFolder = member.AoiQualifier switch + { + AoiQualifier.Input => inputsFolder ??= udtFolder.Folder("Inputs", "Inputs"), + AoiQualifier.Output => outputsFolder ??= udtFolder.Folder("Outputs", "Outputs"), + AoiQualifier.InOut => inOutFolder ??= udtFolder.Folder("InOut", "InOut"), + _ => udtFolder, // Local stays at the AOI root + }; + } var memberFullName = $"{tag.Name}.{member.Name}"; - udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo( + parentFolder.Variable(member.Name, member.Name, new DriverAttributeInfo( FullName: memberFullName, DriverDataType: member.DataType.ToDriverDataType(), IsArray: false, diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs index 2a6f4c7..d76845b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs @@ -153,9 +153,14 @@ public sealed record AbCipTagDefinition( /// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR. /// /// -/// carries the per-member comment from L5K/L5X UDT definitions so +/// carries the per-member comment from L5K/L5X UDT definitions so /// the OPC UA Variable nodes produced for individual members surface their descriptions too, -/// not just the top-level tag. +/// not just the top-level tag. +/// PR abcip-2.6 — tags AOI parameters as Input / Output / +/// InOut / Local. Plain UDT members default to . Discovery +/// groups Input / Output / InOut members under sub-folders so an AOI-typed tag fans out as +/// Tag/Inputs/..., Tag/Outputs/..., Tag/InOut/... while Local stays at the +/// UDT root — matching how AOIs visually present in Studio 5000. /// public sealed record AbCipStructureMember( string Name, @@ -163,7 +168,30 @@ public sealed record AbCipStructureMember( bool Writable = true, bool WriteIdempotent = false, int? StringLength = null, - string? Description = null); + string? Description = null, + AoiQualifier AoiQualifier = AoiQualifier.Local); + +/// +/// PR abcip-2.6 — directional qualifier for AOI parameters. Surfaces the Studio 5000 +/// Usage attribute (Input / Output / InOut) so discovery can group +/// AOI members into sub-folders and downstream consumers can reason about parameter direction. +/// Plain UDT members (non-AOI types) default to , which keeps them at the +/// UDT root + indicates they are internal storage rather than a directional parameter. +/// +public enum AoiQualifier +{ + /// UDT member or AOI local tag — non-directional, browsed at the parent's root. + Local, + + /// AOI input parameter — written by the caller, read by the AOI body. + Input, + + /// AOI output parameter — written by the AOI body, read by the caller. + Output, + + /// AOI bidirectional parameter — passed by reference, both sides may read/write. + InOut, +} /// /// One L5K-import entry. Either or must be diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs index f66e066..9db4d78 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs @@ -59,7 +59,8 @@ public sealed class L5kIngest Name: m.Name, DataType: memberType, Writable: writable, - Description: m.Description)); + Description: m.Description, + AoiQualifier: MapAoiUsage(m.Usage))); } udtIndex[dt.Name] = members; } @@ -119,6 +120,19 @@ public sealed class L5kIngest private static bool IsAccessNone(string? externalAccess) => externalAccess is not null && externalAccess.Trim().Equals("None", StringComparison.OrdinalIgnoreCase); + /// + /// PR abcip-2.6 — map the AOI Usage attribute string to . + /// Plain UDT members (Usage = null) + unrecognised values map to . + /// + private static AoiQualifier MapAoiUsage(string? usage) => + usage?.Trim().ToUpperInvariant() switch + { + "INPUT" => AoiQualifier.Input, + "OUTPUT" => AoiQualifier.Output, + "INOUT" => AoiQualifier.InOut, + _ => AoiQualifier.Local, + }; + /// Map a Logix atomic type name. Returns null for UDT/structure references. private static AbCipDataType? TryMapAtomic(string logixType) => logixType?.Trim().ToUpperInvariant() switch diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs index 6836688..e2a0f5a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs @@ -72,6 +72,16 @@ public static class L5kParser continue; } + // PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION block. AOI parameters carry a Usage + // attribute (Input / Output / InOut); each PARAMETER becomes a member of the AOI's + // L5kDataType entry so AOI-typed tags pick up a layout the same way UDT-typed tags do. + if (StartsWithKeyword(line, "ADD_ON_INSTRUCTION_DEFINITION")) + { + var consumed = ParseAoiDefinitionBlock(lines, i, datatypes); + i += consumed; + continue; + } + i++; } @@ -312,7 +322,74 @@ public static class L5kParser if (typePart.Length == 0) return null; var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null; var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null; - return new L5kMember(name, typePart, arrayDim, externalAccess, description); + // PR abcip-2.6 — Usage attribute on AOI parameters (Input / Output / InOut). Plain UDT + // members don't carry it; null on a regular DATATYPE MEMBER is the default + maps to Local + // in the ingest layer. + var usage = attributes.TryGetValue("Usage", out var u) ? u.Trim() : null; + return new L5kMember(name, typePart, arrayDim, externalAccess, description, usage); + } + + // ---- AOI block --------------------------------------------------------- + + /// + /// PR abcip-2.6 — parse ADD_ON_INSTRUCTION_DEFINITION ... END_ADD_ON_INSTRUCTION_DEFINITION + /// blocks. Body is structured around PARAMETER entries (each carrying a Usage + /// attribute) and optional LOCAL_TAGS / ROUTINE blocks. We extract the parameters as + /// rows + leave routines alone — only the surface API matters for + /// tag-discovery fan-out. The L5K format encloses parameters either inside a + /// PARAMETERS ... END_PARAMETERS block or as bare PARAMETER ... ; lines at + /// the AOI top level depending on Studio 5000 export options; this parser accepts both. + /// + private static int ParseAoiDefinitionBlock(string[] lines, int start, List into) + { + var first = lines[start].Trim(); + var head = first.Substring("ADD_ON_INSTRUCTION_DEFINITION".Length).Trim(); + var name = ExtractFirstQuotedOrToken(head); + var members = new List(); + var i = start + 1; + var inLocalsBlock = false; + var inRoutineBlock = false; + while (i < lines.Length) + { + var line = lines[i].Trim(); + if (StartsWithKeyword(line, "END_ADD_ON_INSTRUCTION_DEFINITION")) + { + if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members)); + return i - start + 1; + } + if (line.Length == 0) { i++; continue; } + + // Skip routine bodies — they hold ladder / ST / FBD code we don't care about for + // tag-discovery, and their own END_ROUTINE / END_LOCAL_TAGS tokens close them out. + if (StartsWithKeyword(line, "ROUTINE")) { inRoutineBlock = true; i++; continue; } + if (StartsWithKeyword(line, "END_ROUTINE")) { inRoutineBlock = false; i++; continue; } + if (StartsWithKeyword(line, "LOCAL_TAGS")) { inLocalsBlock = true; i++; continue; } + if (StartsWithKeyword(line, "END_LOCAL_TAGS")) { inLocalsBlock = false; i++; continue; } + if (inRoutineBlock || inLocalsBlock) { i++; continue; } + + // PARAMETERS / END_PARAMETERS wrappers are skipped — bare PARAMETER lines drive parsing. + if (StartsWithKeyword(line, "PARAMETERS")) { i++; continue; } + if (StartsWithKeyword(line, "END_PARAMETERS")) { i++; continue; } + + if (StartsWithKeyword(line, "PARAMETER")) + { + var sb = new System.Text.StringBuilder(line); + while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length) + { + var peek = lines[i + 1].Trim(); + if (StartsWithKeyword(peek, "END_ADD_ON_INSTRUCTION_DEFINITION")) break; + i++; + sb.Append(' ').Append(peek); + } + var entry = sb.ToString().TrimEnd(';').Trim(); + entry = entry.Substring("PARAMETER".Length).Trim(); + var member = ParseMemberEntry(entry); + if (member is not null) members.Add(member); + } + i++; + } + if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members)); + return i - start; } // ---- helpers ----------------------------------------------------------- @@ -377,10 +454,16 @@ public sealed record L5kTag( /// One UDT definition extracted from a DATATYPE ... END_DATATYPE block. public sealed record L5kDataType(string Name, IReadOnlyList Members); -/// One member line inside a UDT definition. +/// One member line inside a UDT definition or AOI parameter list. +/// +/// PR abcip-2.6 — carries the AOI Usage attribute (Input / +/// Output / InOut) raw text. Plain UDT members + L5K AOI LOCAL_TAGS leave +/// it null; the ingest layer maps null → . +/// public sealed record L5kMember( string Name, string DataType, int? ArrayDim, string? ExternalAccess, - string? Description = null); + string? Description = null, + string? Usage = null); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs index 67f5cda..69d8dd6 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs @@ -218,12 +218,19 @@ public static class L5xParser if (!string.IsNullOrEmpty(raw)) paramDescription = raw.Trim(); } + // PR abcip-2.6 — capture the AOI Usage attribute (Input / Output / InOut). RSLogix + // also serialises Local AOI tags inside , but those don't go through this + // path — only / entries do — so any Usage value on a parameter + // is one of the directional buckets. + var usage = paramNode.GetAttribute("Usage", string.Empty); + members.Add(new L5kMember( Name: paramName, DataType: dataType, ArrayDim: arrayDim, ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess, - Description: paramDescription)); + Description: paramDescription, + Usage: string.IsNullOrEmpty(usage) ? null : usage)); } return new L5kDataType(name, members); } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs index 0b369c2..8174386 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs @@ -167,6 +167,83 @@ public sealed class AbCipUdtMemberTests builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt"); } + [Fact] + public async Task AOI_typed_tag_groups_members_under_directional_subfolders() + { + // PR abcip-2.6 — when any member carries a non-Local AoiQualifier, the tag is treated + // as an AOI instance: Input / Output / InOut members get grouped under sub-folders so + // the browse tree mirrors Studio 5000's AOI parameter tabs. Plain UDT tags (every member + // Local) keep the pre-2.6 flat layout. + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = + [ + new AbCipTagDefinition( + Name: "Valve_001", + DeviceHostAddress: "ab://10.0.0.5/1,0", + TagPath: "Valve_001", + DataType: AbCipDataType.Structure, + Members: + [ + new AbCipStructureMember("Cmd", AbCipDataType.Bool, AoiQualifier: AoiQualifier.Input), + new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false, AoiQualifier: AoiQualifier.Output), + new AbCipStructureMember("Buffer", AbCipDataType.DInt, AoiQualifier: AoiQualifier.InOut), + new AbCipStructureMember("LocalVar", AbCipDataType.DInt, AoiQualifier: AoiQualifier.Local), + ]), + ], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + // Sub-folders for each directional bucket land in the recorder; the AOI parent folder + // and the Local member's lack of a sub-folder confirm only directional members get + // bucketed. Folder names are intentionally simple (Inputs / Outputs / InOut) — clients + // that browse "Valve_001/Inputs/Cmd" see exactly that path. + builder.Folders.Select(f => f.BrowseName).ShouldContain("Valve_001"); + builder.Folders.Select(f => f.BrowseName).ShouldContain("Inputs"); + builder.Folders.Select(f => f.BrowseName).ShouldContain("Outputs"); + builder.Folders.Select(f => f.BrowseName).ShouldContain("InOut"); + + // Variables emitted under the right full names — full reference still {Tag}.{Member} + // so the read/write paths stay unchanged from the flat-UDT case. + var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList(); + variables.ShouldContain(("Cmd", "Valve_001.Cmd")); + variables.ShouldContain(("Status", "Valve_001.Status")); + variables.ShouldContain(("Buffer", "Valve_001.Buffer")); + variables.ShouldContain(("LocalVar", "Valve_001.LocalVar")); + } + + [Fact] + public async Task Plain_UDT_keeps_flat_layout_when_every_member_is_Local() + { + // Plain UDTs (no Usage attributes anywhere) stay on the pre-2.6 flat layout — no + // Inputs/Outputs/InOut sub-folders should appear since there are no directional members. + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = + [ + new AbCipTagDefinition("Tank1", "ab://10.0.0.5/1,0", "Tank1", AbCipDataType.Structure, + Members: + [ + new AbCipStructureMember("Level", AbCipDataType.Real), + new AbCipStructureMember("Pressure", AbCipDataType.Real), + ]), + ], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Inputs"); + builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Outputs"); + builder.Folders.Select(f => f.BrowseName).ShouldNotContain("InOut"); + } + [Fact] public async Task UDT_members_mixed_with_flat_tags_coexist() { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs index 1bd949c..677703e 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs @@ -153,6 +153,81 @@ public sealed class L5kIngestTests Should.Throw(() => new L5kIngest().Ingest(doc)); } + [Fact] + public void AOI_member_Usage_maps_to_AoiQualifier_through_ingest() + { + // PR abcip-2.6 — L5K AOI parameters carry a Usage := Input / Output / InOut attribute. + // Ingest must map those values onto AbCipStructureMember.AoiQualifier so the discovery + // layer can group AOI members under sub-folders. Plain DATATYPE members get Local. + const string body = """ + ADD_ON_INSTRUCTION_DEFINITION ValveAoi + PARAMETERS + PARAMETER Cmd : BOOL (Usage := Input) := 0; + PARAMETER Status : DINT (Usage := Output) := 0; + PARAMETER Buffer : DINT (Usage := InOut) := 0; + PARAMETER Local1 : DINT := 0; + END_PARAMETERS + END_ADD_ON_INSTRUCTION_DEFINITION + DATATYPE PlainUdt + MEMBER Speed : DINT := 0; + END_DATATYPE + TAG + Valve_001 : ValveAoi; + Tank1 : PlainUdt; + END_TAG + """; + var doc = L5kParser.Parse(new StringL5kSource(body)); + + var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc); + + var aoiTag = result.Tags.Single(t => t.Name == "Valve_001"); + aoiTag.Members.ShouldNotBeNull(); + aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input); + aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output); + aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut); + aoiTag.Members.Single(m => m.Name == "Local1").AoiQualifier.ShouldBe(AoiQualifier.Local); + + // Plain UDT members default to Local — no Usage attribute to map. + var plainTag = result.Tags.Single(t => t.Name == "Tank1"); + plainTag.Members.ShouldNotBeNull(); + plainTag.Members!.Single().AoiQualifier.ShouldBe(AoiQualifier.Local); + } + + [Fact] + public void L5x_AOI_member_Usage_maps_to_AoiQualifier_through_ingest() + { + // Same mapping as the L5K case above, exercised through the L5X parser to confirm both + // formats land at the same downstream representation. + const string body = """ + + + + + + + + + + + + + + + + + + """; + var doc = L5xParser.Parse(new StringL5kSource(body)); + + var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc); + + var aoiTag = result.Tags.Single(); + aoiTag.Members.ShouldNotBeNull(); + aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input); + aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output); + aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut); + } + [Fact] public void NamePrefix_is_applied_to_imported_tags() { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs index e26a414..a761385 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs @@ -150,6 +150,36 @@ public sealed class L5kParserTests doc.Tags[0].Name.ShouldBe("Real_Tag"); } + [Fact] + public void AOI_definition_block_collects_parameters_with_Usage() + { + // PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION blocks with PARAMETER entries carrying + // Usage := Input / Output / InOut. The parser surfaces them as L5kDataType members so + // AOI-typed tags pick up a layout the same way UDT-typed tags do. + const string body = """ + ADD_ON_INSTRUCTION_DEFINITION MyValveAoi (Revision := "1.0") + PARAMETERS + PARAMETER Cmd : BOOL (Usage := Input) := 0; + PARAMETER Status : DINT (Usage := Output, ExternalAccess := Read Only) := 0; + PARAMETER Buffer : DINT (Usage := InOut) := 0; + PARAMETER Internal : DINT := 0; + END_PARAMETERS + LOCAL_TAGS + Working : DINT := 0; + END_LOCAL_TAGS + END_ADD_ON_INSTRUCTION_DEFINITION + """; + + var doc = L5kParser.Parse(new StringL5kSource(body)); + + var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi"); + aoi.Members.Count.ShouldBe(4); + aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input"); + aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output"); + aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut"); + aoi.Members.Single(m => m.Name == "Internal").Usage.ShouldBeNull(); + } + [Fact] public void Multi_line_TAG_entry_is_concatenated() { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs index f708ddf..f6de06b 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs @@ -191,6 +191,35 @@ public sealed class L5xParserTests tag.Members!.Select(m => m.Name).ShouldBe(["Cmd", "Status"]); } + [Fact] + public void AOI_parameter_Usage_attribute_is_captured() + { + // PR abcip-2.6 — Usage attribute on elements (Input / Output / InOut) flows + // through to L5kMember.Usage so the ingest layer can map it to AoiQualifier. + const string body = """ + + + + + + + + + + + + + + + """; + + var doc = L5xParser.Parse(new StringL5kSource(body)); + var aoi = doc.DataTypes.Single(d => d.Name == "MyAoi"); + aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input"); + aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output"); + aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut"); + } + [Fact] public void Empty_or_minimal_document_returns_empty_bundle_without_throwing() {